diff --git a/.assets-revision b/.assets-revision index 5c22104e..c3617c2e 100644 --- a/.assets-revision +++ b/.assets-revision @@ -5,4 +5,9 @@ # is a git revision (branch name like `main`, a tag, or a specific commit # sha). Override at runtime with the ASSETS_REVISION env var. repo: ChilleD/WebHarbor -revision: 54882a6a66a17a3e43455057e7c9e0d103cd8b81 +# Pinned to HF PR #47 (adds google_finance.tar.gz on top of current main) so +# this PR builds end-to-end before the assets PR is merged. This ref contains +# all 17 site tarballs. Maintainer: once +# https://huggingface.co/datasets/ChilleD/WebHarbor/discussions/47 is merged, +# bump this to the merged commit SHA on main. +revision: refs/pr/47 diff --git a/Dockerfile b/Dockerfile index 1e86b1d0..4eadd576 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # WebHarbor — slim, self-contained image. -# 16 Flask mirror sites + control plane on :8101. +# 17 Flask mirror sites + control plane on :8101. FROM python:3.12-slim-bookworm @@ -16,6 +16,7 @@ RUN pip3 install --no-cache-dir \ Flask-Bcrypt==1.0.1 \ Werkzeug==3.1.3 \ Jinja2==3.1.4 \ + MarkupSafe==3.0.3 \ SQLAlchemy==2.0.36 \ WTForms==3.2.1 \ email-validator==2.2.0 \ @@ -33,6 +34,6 @@ COPY control_server.py /opt/control_server.py COPY site_runner.py /opt/site_runner.py RUN chmod +x /opt/websyn_start.sh -EXPOSE 8101 40000-40015 +EXPOSE 8101 40000-40016 CMD ["/opt/websyn_start.sh"] diff --git a/control_server.py b/control_server.py index 4b6b995e..78a37eda 100644 --- a/control_server.py +++ b/control_server.py @@ -26,7 +26,7 @@ 'allrecipes', 'amazon', 'apple', 'arxiv', 'bbc_news', 'booking', 'github', 'google_flights', 'google_map', 'google_search', 'huggingface', 'wolfram_alpha', 'cambridge_dictionary', - 'coursera', 'espn', 'merriam_webster', + 'coursera', 'espn', 'merriam_webster', 'google_finance', ] BASE_PORT = 40000 WEBSYN_DIR = '/opt/WebSyn' diff --git a/sites/google_finance/_companies.py b/sites/google_finance/_companies.py new file mode 100644 index 00000000..9b068aab --- /dev/null +++ b/sites/google_finance/_companies.py @@ -0,0 +1,2997 @@ +"""Company identities, publisher list and magnitude anchors. + +GENERATED by _dev/build_company_module.py from the Playwright recon of +google.com/finance — do not hand-edit. Names, logos, About blocks and profile +prose are the real values from the live site. ANCHORS carries only order-of- +magnitude inputs (a recent open price, share count, traded volume); the frozen +values the mirror actually serves are synthesized from them in _market_data.py. +""" + +COMPANIES = {'AAPL:NASDAQ': {'about': {'CEO': 'Tim Cook', + 'Employees': '166K', + 'Founded': 'Apr 1, 1976', + 'Headquarters': 'Cupertino, California, United States', + 'Sector': 'Computers, Peripherals, and Software'}, + 'logo_file': 'logos/AAPL.svg', + 'name': 'Apple Inc', + 'profile': 'Apple Inc. is an American multinational technology company ' + 'headquartered in Cupertino, California, in Silicon Valley, and ' + 'known for consumer electronics, software and online services. ' + 'Founded in 1976 as Apple Computer Company by Steve Jobs, Steve ' + 'Wozniak and Ronald Wayne, the company was incorporated by Jobs ' + 'and Wozniak as Apple Computer, Inc. the following year. Its ' + 'current name was adopted in 2007 as the company expanded its ' + 'focus from computers to consumer electronics. Apple is one of the ' + "Big Tech companies. The company was founded to market Wozniak's " + 'Apple I computer. Its successor, the Apple II, became one of the ' + 'first successful mass-produced personal microcomputers. Apple ' + 'introduced the Lisa in 1983 and the Macintosh in 1984, ' + 'popularizing graphical user interfaces navigated by a mouse. By ' + '1985, internal conflicts led to Jobs leaving the company to form ' + 'NeXT and Wozniak stepping back from active employment. During the ' + '1990s, Apple lost considerable market share to lower-priced ' + 'Wintel computers, Intel-powered PC clones running Windows.', + 'related': ['AMZN:NASDAQ', 'MSFT:NASDAQ', 'TSLA:NASDAQ', 'NFLX:NASDAQ']}, + 'ABBV:NYSE': {'about': {'CEO': 'Robert A. Michael', + 'Employees': '57K', + 'Founded': 'Apr 10, 2012', + 'Headquarters': 'North Chicago, Illinois, United States', + 'Sector': 'Biotechnology', + 'Website': 'abbvie.com'}, + 'logo_file': 'logos/ABBV.svg', + 'name': 'AbbVie Common Stock', + 'profile': 'AbbVie Inc., also known as AbbVie, is an American pharmaceutical ' + 'company headquartered in North Chicago, Illinois. AbbVie produces ' + 'drugs to treat a wide range of medical issues. The company is ' + 'ranked sixth on the list of largest biomedical companies by ' + 'revenue. It is ranked 77th on the Fortune 500 and 108th on the ' + 'Forbes Global 2000. The name "AbbVie" is derived from a combination ' + 'of "Abbott", the name of its former parent company, with "vie", ' + "intended as a reference to a Latin root meaning 'life'.", + 'related': ['ABT:NYSE', 'JNJ:NYSE', 'LLY:NYSE', 'AMAT:NASDAQ']}, + 'ABNB:NASDAQ': {'about': {'CEO': 'Brian Chesky', + 'Employees': '8.2K', + 'Founded': '2007', + 'Headquarters': 'San Francisco, California, United States', + 'Sector': 'Resort', + 'Website': 'airbnb.com'}, + 'logo_file': 'logos/ABNB.svg', + 'name': 'Airbnb Inc', + 'profile': 'Airbnb, Inc. is an American company operating an online ' + 'marketplace for short-and-long-term homestays, experiences and ' + 'services in various countries and regions. It acts as a broker ' + 'and charges a commission from each booking. Airbnb was founded in ' + '2008 by Brian Chesky, Nathan Blecharczyk, and Joe Gebbia.', + 'related': ['BKNG:NASDAQ', 'NOW:NYSE']}, + 'ABT:NYSE': {'about': {'CEO': 'Robert B. Ford', + 'Employees': '115K', + 'Founded': '1888', + 'Headquarters': 'Chicago, Illinois, United States', + 'Sector': 'Medical device', + 'Website': 'abbott.com'}, + 'logo_file': 'logos/ABT.svg', + 'name': 'Abbott Laboratories', + 'profile': 'Abbott Laboratories, commonly known as Abbott, is an American ' + 'multinational medical devices and health care company founded in ' + 'Chicago with headquarters in Abbott Park, Illinois. The company ' + 'produces pharmaceuticals for sale outside the United States, ' + 'diagnostic products, nutritional products, and medical devices. ' + 'Abbott operates in over 160 countries and sources from at least 62 ' + "suppliers worldwide. Abbott's products include Pedialyte, Similac, " + 'BinaxNOW, Ensure, Glucerna, ZonePerfect, FreeStyle Libre, i-STAT and ' + 'MitraClip. The company is ranked 107th on the Fortune 500 and 144th ' + 'on the Forbes Global 2000.', + 'related': ['ABBV:NYSE', 'PFE:NYSE', 'CMCSA:NASDAQ', 'INTC:NASDAQ']}, + 'ACN:NYSE': {'about': {'CEO': 'Jean-Marc Ollagnier | Julie Sweet', + 'Employees': '799K', + 'Founded': '1989', + 'Sector': 'Information technology consulting', + 'Website': 'accenture.com'}, + 'logo_file': 'logos/ACN.svg', + 'name': 'Accenture Plc', + 'profile': 'Accenture plc is an Irish domiciled technology consulting company ' + 'headquartered in Dublin, Ireland for tax purposes. Founded in 1989 ' + 'in the United States, Accenture provides information technology and ' + 'management consulting services across 120 countries globally. ' + 'Wikipedia\n' + 'About Accenture Plc\n' + 'CEO\n' + 'Jean-Marc Ollagnier | Julie Sweet\n' + 'Employees\n' + '799K\n' + 'Founded\n' + '1989\n' + 'Headquarters\n' + '-\n' + 'Sector\n' + 'Information technology consulting\n' + 'Website\n' + 'accenture.com', + 'related': ['AMZN:NASDAQ', 'SBUX:NASDAQ', 'ADBE:NASDAQ']}, + 'ADBE:NASDAQ': {'about': {'CEO': 'Aphinant Klewpatinond | Shantanu Narayen', + 'Employees': '31.4K', + 'Founded': 'Dec 1982', + 'Headquarters': 'San Jose, California, United States', + 'Sector': 'Entertainment'}, + 'logo_file': 'logos/ADBE.svg', + 'name': 'Adobe Inc', + 'profile': 'Adobe Inc., formerly Adobe Systems Incorporated, is an American ' + 'multinational computer software company based in San Jose, ' + 'California. It offers a wide range of programs from web design ' + 'tools, photo manipulation, and vector creation to video and audio ' + 'editing, mobile app development, print layout and animation ' + 'software. It has historically specialized in software for the ' + 'creation and publication of a wide range of content, including ' + 'graphics, photography, illustration, animation, multimedia/video, ' + 'motion pictures and print. Its flagship products include Adobe ' + 'Photoshop image editing software; Adobe Illustrator vector-based ' + 'illustration software; Adobe Acrobat and the Portable Document ' + 'Format; and a host of tools primarily for audio-visual content ' + 'creation, editing, and publishing. Adobe offered a bundled ' + 'solution of its products named Adobe Creative Suite, which ' + 'evolved into a subscription-based offering named Adobe Creative ' + 'Cloud. The company also expanded into digital marketing software ' + 'and in 2021 was considered one of the top global leaders in ' + 'Customer Experience Management.', + 'related': ['MSFT:NASDAQ', + 'CRM:NYSE', + 'NOW:NYSE', + 'SPY:NYSEARCA', + 'NVDA:NASDAQ']}, + 'AEP:NASDAQ': {'about': {'CEO': 'William J. Fehrman', + 'Employees': '17.6K', + 'Founded': '1906', + 'Headquarters': 'Columbus, Ohio, United States', + 'Sector': 'Public utility', + 'Website': 'aep.com'}, + 'logo_file': 'logos/AEP.svg', + 'name': 'American Electric Power Company Inc', + 'profile': 'American Electric Power Company, Inc. is an American domestic ' + 'electric utility company in the United States. It is one of the ' + 'largest electric utility companies in the country, with more than ' + 'five million customers in 11 states. American Electric Power ' + "Company is one of the nation's largest generators of electricity, " + 'owning nearly 38,000 megawatts of generating capacity in the ' + "United States. AEP also owns the nation's largest electricity " + 'transmission system, a nearly 39,000-mile network that includes ' + 'more 765 kilovolt ultra-high voltage transmission lines than all ' + "other U.S. transmission systems combined. AEP's transmission " + 'system directly or indirectly provides about 10 percent of the ' + 'electricity demand in the Eastern Interconnection, the ' + 'interconnected transmission system that covers 38 eastern and ' + 'central U.S. states and eastern Canada, and approximately 11 ' + 'percent of the electricity demand in the Electric Reliability ' + 'Council of Texas, the transmission system that covers much of ' + "Texas. AEP's utility units operate as AEP Ohio, AEP Texas, " + 'Appalachian Power, Indiana Michigan Power, Kentucky Power, Public ' + 'Service Company of Oklahoma, and Southwestern Electric Power ' + 'Company.', + 'related': ['EXC:NASDAQ', 'NEE:NYSE']}, + 'AMAT:NASDAQ': {'about': {'CEO': 'Gary E. Dickerson', + 'Employees': '36.4K', + 'Founded': 'Nov 10, 1967', + 'Headquarters': 'Santa Clara, California, United States', + 'Sector': 'Semiconductor', + 'Website': 'appliedmaterials.com'}, + 'logo_file': 'logos/AMAT.svg', + 'name': 'Applied Materials Inc', + 'profile': 'Applied Materials, Inc. is an American corporation that supplies ' + 'equipment, services and software for the manufacture of ' + 'semiconductor chips for electronics, flat panel displays for ' + 'computers, smartphones, televisions, and solar products. The ' + 'company also supplies equipment to produce coatings for flexible ' + 'electronics, packaging and other applications. The company is ' + 'headquartered in Santa Clara, California, and is the second ' + 'largest supplier of semiconductor equipment in the world based on ' + 'revenue behind Dutch company ASML.', + 'related': ['MU:NASDAQ', 'NVDA:NASDAQ']}, + 'AMD:NASDAQ': {'about': {'CEO': 'Lisa Su', + 'Employees': '31K', + 'Founded': 'May 1, 1969', + 'Headquarters': 'Santa Clara, California, United States', + 'Sector': 'Semiconductor', + 'Website': 'amd.com'}, + 'logo_file': 'logos/AMD.svg', + 'name': 'Advanced Micro Devices Inc', + 'profile': 'Advanced Micro Devices, Inc. is an American multinational ' + 'semiconductor company headquartered in Santa Clara, California. It ' + 'develops central processing units, graphics processing units, ' + 'field-programmable gate arrays, system-on-chips, and ' + 'high-performance computer components. AMD serves a wide range of ' + 'business and consumer markets, including personal computers, ' + "gaming, data centers, and embedded systems. AMD's main products " + 'include microprocessors, embedded processors, and graphics ' + 'processors for servers, workstations, PCs, and embedded system ' + 'applications, as well as chipsets for motherboards. The company ' + 'has also expanded into new markets, such as data centers, gaming, ' + "and high-performance computing. AMD's processors are used in a " + 'wide range of computing devices, including PCs, servers, laptops, ' + 'and gaming consoles. Initially manufacturing its own processors, ' + 'the company outsourced its manufacturing after GlobalFoundries was ' + 'spun off in 2009. Through its Xilinx acquisition in 2022, AMD ' + 'offers field-programmable gate array products. AMD was founded in ' + '1969 by Jerry Sanders and a group of his fellow Fairchild ' + 'Semiconductor colleagues.', + 'related': ['MU:NASDAQ', 'NVDA:NASDAQ', 'INTC:NASDAQ', 'AMZN:NASDAQ']}, + 'AMGN:NASDAQ': {'about': {'CEO': 'Robert A. Bradway', + 'Employees': '31.5K', + 'Founded': 'Apr 8, 1980', + 'Headquarters': 'Thousand Oaks, California, United States', + 'Sector': 'Biotechnology', + 'Website': 'amgen.com'}, + 'logo_file': 'logos/AMGN.svg', + 'name': 'Amgen Inc', + 'profile': 'Amgen Inc. is an American multinational biopharmaceutical company ' + 'headquartered in Thousand Oaks, California. The company is ranked ' + '18th on the list of largest biomedical companies by revenue. The ' + 'name "AMGen" is a portmanteau of the company\'s original name, ' + "Applied Molecular Genetics. The company's major products are " + 'Prolia and XGEVA to treat osteoporosis and bone diseases, Repatha ' + 'to treat hyperlipidemia, Otezla to treat psoriasis and psoriatic ' + 'arthritis, Enbrel to treat autoimmune diseases, Evenity to treat ' + "osteoporosis, Tepezza to treat Graves' ophthalmopathy, Blincyto " + 'to treat acute lymphoblastic leukemia, Nplate to regulate ' + 'platelet production, Tezspire to treat asthma, Kyprolis to treat ' + 'cancer, Aranesp to stimulate erythropoiesis, Krystexxa to treat ' + 'gout, and Vectibix, a monoclonal antibody. Amgen has 17 clinical ' + 'programs underway in Phase III, eight in Phase II, and 19 in ' + 'Phase I. Its pipeline includes MariTide, an anti-obesity ' + 'medication administered once per month by injection. The company ' + 'receives approximately 80% of its revenues from sales to the ' + 'three large U.S. drug wholesalers: McKesson Corporation, Cencora, ' + 'and Cardinal Health.', + 'related': ['NVDA:NASDAQ', 'ABBV:NYSE', 'AMZN:NASDAQ']}, + 'AMT:NYSE': {'about': {'CEO': 'Steven Vondran | Steven Vondran', + 'Employees': '4.87K', + 'Founded': '1995', + 'Sector': 'Real estate investment trust', + 'Website': 'americantower.com'}, + 'logo_file': 'logos/AMT.svg', + 'name': 'American Tower Corp', + 'profile': 'American Tower Corporation is an American real estate investment ' + 'trust which owns, develops and operates wireless and broadcast ' + 'communications infrastructure in several countries. It is ' + 'headquartered in Boston, Massachusetts. As of December 31, 2025, the ' + 'company owns 149,686 communications sites, including 42,224 sites in ' + 'the U.S. and Canada, 27,857 sites in Asia-Pacific and Africa, 32,524 ' + 'sites in Europe, and 47,081 sites in Latin America.', + 'related': ['EQIX:NASDAQ', 'PLD:NYSE']}, + 'AMZN:NASDAQ': {'about': {'CEO': 'Andy Jassy', + 'Employees': '1.58M', + 'Founded': 'Jul 5, 1994', + 'Headquarters': 'Seattle, Washington, United States', + 'Sector': 'Consumer', + 'Website': 'amazon.com'}, + 'logo_file': 'logos/AMZN.svg', + 'name': 'Amazon.com Inc', + 'profile': 'Amazon.com, Inc. is an American multinational technology company ' + 'engaged in e-commerce, cloud computing, online advertising, ' + 'digital streaming, entertainment, and artificial intelligence. ' + 'Founded in 1994 by Jeff Bezos in Bellevue, Washington, the ' + 'company originally started as an online marketplace for books but ' + 'gradually expanded its offerings to include a wide range of ' + 'product categories, referred to as "The Everything Store". Amazon ' + 'has been described as a Big Tech company. It is best known as the ' + "world's biggest online shopping retailer and marketplace, " + 'offering a wide range of products and services. Amazon sells ' + 'books, electronics, pharmaceuticals, car parts, video games, ' + 'dining products, jewelry, food, music, and sports equipment. The ' + 'company has multiple subsidiaries, including Amazon Web Services, ' + 'which provides cloud computing; Zoox, a self-driving car ' + 'division; Kuiper Systems, a satellite Internet provider; and ' + 'Amazon Lab126, a computer hardware R&D provider. Other ' + 'subsidiaries include Ring, Twitch, IMDb, and Whole Foods Market.', + 'related': ['AAPL:NASDAQ', 'MSFT:NASDAQ', 'TSLA:NASDAQ', 'NFLX:NASDAQ']}, + 'APD:NYSE': {'about': {'CEO': 'Eduardo Menezes Uninformative buy 12/03/2025 17.4K $4.45M', + 'Employees': '21.2K', + 'Founded': '1940', + 'Headquarters': 'Allentown, Pennsylvania, United States', + 'Sector': 'Industrial gas', + 'Website': 'airproducts.com'}, + 'logo_file': 'logos/APD.svg', + 'name': 'Air Products and Chemicals Inc', + 'profile': 'Air Products and Chemicals, Inc. is a U.S.-based international ' + 'corporation whose principal business is selling gases and chemicals ' + 'for industrial use. Air Products is headquartered in Trexlertown, ' + 'Pennsylvania, in the Lehigh Valley region of eastern Pennsylvania. ' + 'As of 2024, Air Products is the fifth-largest employer in the Lehigh ' + 'Valley.', + 'related': ['LIN:NASDAQ']}, + 'ARKK:BATS': {'about': {}, + 'logo_file': 'logos/ARKK.svg', + 'name': 'ARK Innovation ETF', + 'profile': '', + 'related': ['QQQ:NASDAQ', 'SPY:NYSEARCA']}, + 'AVGO:NASDAQ': {'about': {'CEO': 'Hock E. Tan', + 'Employees': '33K', + 'Founded': '1961', + 'Sector': 'Semiconductor', + 'Website': 'broadcom.com'}, + 'logo_file': 'logos/AVGO.svg', + 'name': 'Broadcom Inc', + 'profile': 'Broadcom Inc. is an American multinational designer, developer, ' + 'manufacturer, and global supplier of a wide range of ' + "semiconductor and infrastructure software products. Broadcom's " + 'product offerings serve the data center, networking, software, ' + 'broadband, wireless, storage, and industrial markets. As of 2026, ' + 'Broadcom is one of the largest companies globally. In December ' + '2024, Broadcom became the 12th company to surpass a $1 trillion ' + 'market cap. In April 2026, Broadcom surpassed a $2 trillion ' + 'market cap, becoming the sixth company in history to do so. As of ' + "2025, some 58% of Broadcom's revenue came from its " + 'semiconductor-based products and 42% from its infrastructure ' + "software products and services. Tan Hock Eng is the company's " + 'president and CEO. The company is headquartered in Palo Alto, ' + 'California. Avago Technologies Limited changed its name to ' + "Broadcom to acquire Broadcom Corporation in January 2016. Avago's " + 'ticker symbol AVGO now represents the merged entity. The Broadcom ' + 'Corporation ticker symbol BRCM was retired. Initially the merged ' + 'entity was known as Broadcom Limited, before assuming the current ' + 'name in November 2017.', + 'related': ['AMZN:NASDAQ', 'AAPL:NASDAQ', 'MU:NASDAQ', 'TSLA:NASDAQ']}, + 'AXP:NYSE': {'about': {'CEO': 'Stephen Squeri', + 'Employees': '76.8K', + 'Founded': 'Mar 18, 1850', + 'Headquarters': 'New York, New York, United States', + 'Sector': 'Payment service provider', + 'Website': 'americanexpress.com'}, + 'logo_file': 'logos/AXP.svg', + 'name': 'American Express Co', + 'profile': 'American Express Company is an American bank holding company and ' + 'multinational financial services corporation that specializes in ' + 'payment cards. It is headquartered at 200 Vesey Street, also known ' + 'as American Express Tower, in the Battery Park City neighborhood of ' + 'Lower Manhattan; in 2026, the company announced that it would ' + 'develop and be the sole occupant of the new 2 World Trade Center ' + 'skyscraper in the nearby World Trade Center complex. Amex is the ' + 'fourth-largest card network globally based on purchase volume, ' + 'behind China UnionPay, Visa, and Mastercard. 141.2 million Amex ' + 'cards were in force worldwide as of December 31, 2023, with an ' + 'average annual spend per card member of US$24,059. That year, Amex ' + 'handled over $1.7 trillion in purchase volume on its network. Amex ' + 'is the 16th largest US bank, with a total of US$270 billion in ' + 'assets or 1.1% of all assets insured by the FDIC. It is ranked 58th ' + 'on the Fortune 500 and 28th on the list of the most valuable brands ' + 'by Forbes. In 2023, it was ranked 63rd in the Forbes Global 2000. ' + 'American Express National Bank is a direct bank owned by Amex.', + 'related': ['JPM:NYSE', 'MMM:NYSE', 'AAPL:NASDAQ', 'VOO:NYSEARCA', 'BAC:NYSE']}, + 'BA:NYSE': {'about': {'CEO': 'Kelly Ortberg', + 'Employees': '182K', + 'Founded': 'Jul 15, 1916', + 'Sector': 'Aerospace and Defense', + 'Website': 'boeing.com'}, + 'logo_file': 'logos/BA.svg', + 'name': 'Boeing Co', + 'profile': 'The Boeing Company is an American multinational corporation that ' + 'designs and manufactures airplanes, rotorcraft, rockets, satellites, ' + 'and missiles worldwide. The company also provides leasing and product ' + 'support services. Boeing is among the largest global aerospace ' + 'manufacturers; it is the fourth-largest defense contractor in the ' + 'world based on 2022 revenue and is the largest exporter in the United ' + 'States by dollar value. Boeing was founded in 1916 by William E. ' + 'Boeing in Seattle, Washington. The present corporation is the result ' + 'of the merger of Boeing with McDonnell Douglas on August 1, 1997. As ' + "of 2023, the Boeing Company's corporate headquarters is located in " + 'the Crystal City neighborhood of Arlington County, Virginia. The ' + 'company is organized into three primary divisions: Boeing Commercial ' + 'Airplanes, Boeing Defense, Space & Security, and Boeing Global ' + 'Services. In 2021, Boeing recorded $62.3 billion in sales. Boeing is ' + 'ranked 54th on the Fortune 500 list, and ranked 121st on the Fortune ' + 'Global 500 list.', + 'related': ['CAT:NYSE', 'SPY:NYSEARCA', 'GE:NYSE', 'RTX:NYSE']}, + 'BAC:NYSE': {'about': {'CEO': 'Brian Moynihan', + 'Employees': '211K', + 'Founded': 'Sep 30, 1998', + 'Sector': 'Bank', + 'Website': 'bankofamerica.com'}, + 'logo_file': 'logos/BAC.svg', + 'name': 'Bank of America Corp', + 'profile': 'The Bank of America Corporation is an American multinational bank ' + 'and financial services holding company headquartered at the Bank of ' + 'America Corporate Center in Charlotte, North Carolina, with ' + 'investment banking and auxiliary headquarters in Manhattan. The bank ' + 'was formed by the merger of NationsBank and Bank of America in 1998. ' + 'It is the second-largest banking institution in the United States ' + 'and the second-largest bank in the world by market capitalization, ' + 'both after JPMorgan Chase. Bank of America is one of the Big Four ' + 'banking institutions of the United States, and one of eight ' + 'systemically important financial institutions in the United States. ' + 'It serves about 10 percent of all American bank deposits, in ' + 'competition with JPMorgan Chase, Citigroup, and Wells Fargo. Its ' + 'primary financial services revolve around commercial banking, wealth ' + 'management, and investment banking. Through mergers, the oldest ' + 'branch of the Bank of America franchise dates back to 1784, when ' + 'Massachusetts Bank was chartered, becoming the first federally ' + 'chartered joint-stock-owned bank in the United States.', + 'related': ['JPM:NYSE', 'C:NYSE', 'WFC:NYSE', 'F:NYSE']}, + 'BKNG:NASDAQ': {'about': {'CEO': 'Glenn D. Fogel', + 'Employees': '24.9K', + 'Founded': '1996', + 'Headquarters': 'Norwalk, Connecticut, United States', + 'Sector': 'Resort', + 'Website': 'bookingholdings.com'}, + 'logo_file': 'logos/BKNG.svg', + 'name': 'Booking Holdings Inc', + 'profile': 'Booking Holdings Inc. is an American travel technology company ' + 'based in Norwalk, Connecticut, that owns and operates several ' + 'travel fare aggregators and travel fare metasearch engines ' + 'including namesake and flagship Booking.com, Priceline.com, ' + 'Agoda, Kayak, Cheapflights, Rentalcars.com, Momondo, and ' + 'OpenTable. It operates websites in about 40 languages and 200 ' + 'countries. The company is ranked 243rd on the Fortune 500 list of ' + 'the largest United States corporations by revenue. The company ' + 'primarily derives its revenue from commissions, with a small ' + 'portion derived from advertising. In 2023, consumers booked 1.235 ' + 'billion room nights of accommodation, 88 million rental car days, ' + 'and 68 million airplane tickets using websites owned by Booking ' + 'Holdings.', + 'related': ['AMZN:NASDAQ', 'NFLX:NASDAQ', 'AAPL:NASDAQ']}, + 'BLK:NYSE': {'about': {'CEO': 'Larry Fink | Larry Fink', + 'Employees': '25.4K', + 'Founded': '1988', + 'Sector': 'Capital market', + 'Website': 'blackrock.com'}, + 'logo_file': 'logos/BLK.svg', + 'name': 'BlackRock Inc', + 'profile': 'BlackRock, Inc. is an American multinational investment company. ' + 'Founded in 1988, initially as an enterprise risk management and ' + 'fixed income institutional asset manager, BlackRock is by far the ' + "world's largest asset manager, with $15.3 trillion in assets under " + 'management as of 2026. Headquartered in New York City, BlackRock has ' + '70 offices in 30 countries and clients in 100 countries. BlackRock ' + 'is the manager of the iShares group of exchange-traded funds, and ' + 'along with Fidelity, Vanguard, and State Street, it is considered ' + 'one of the Big Four index fund managers. Its Aladdin software keeps ' + 'track of investment portfolios for many major financial institutions ' + 'and its BlackRock Solutions division provides financial risk ' + 'management services. As of 2025, BlackRock was ranked 210th on the ' + 'Fortune 500 list of the largest U.S. corporations by revenue. ' + 'BlackRock has sought to position itself as an industry leader in ' + 'environmental, social, and governance considerations in investments. ' + 'The states of West Virginia, Florida, and Louisiana have divested ' + 'money from or refuse to do business with the firm because of its ESG ' + 'policies.', + 'related': ['SCHW:NYSE', 'GS:NYSE']}, + 'C:NYSE': {'about': {'CEO': 'Jane Fraser', + 'Employees': '219K', + 'Founded': 'Oct 8, 1998', + 'Headquarters': 'New York, New York, United States', + 'Sector': 'Bank', + 'Website': 'citigroup.com'}, + 'logo_file': 'logos/C.svg', + 'name': 'Citigroup Inc', + 'profile': 'Citigroup Inc. or Citi is an American multinational investment bank ' + 'and financial services company based in New York City. The company was ' + 'formed in 1998 by the merger of Citicorp, the bank holding company for ' + 'Citibank, and Travelers; Travelers was spun off from the company in ' + '2002. Citigroup is the third-largest banking institution in the United ' + 'States by assets; alongside JPMorgan Chase, Bank of America, and Wells ' + 'Fargo, it is one of the Big Four banking institutions of the United ' + 'States. It is considered a systemically important bank by the ' + 'Financial Stability Board and is commonly called "too big to fail". It ' + 'is one of the eight global investment banks in the Bulge Bracket. ' + 'Citigroup is ranked 36th on the Fortune 500, and was ranked #24 in ' + 'Forbes Global 2000 in 2023.The company has assets under management of ' + 'around US$2,8T.', + 'related': ['WFC:NYSE', 'BAC:NYSE', 'JPM:NYSE']}, + 'CAT:NYSE': {'about': {'CEO': 'Joseph E. Creed', + 'Employees': '118K', + 'Founded': 'Apr 15, 1925', + 'Sector': 'Machine industry', + 'Website': 'caterpillar.com'}, + 'logo_file': 'logos/CAT.svg', + 'name': 'Caterpillar Inc', + 'profile': 'Caterpillar Inc. is an American construction, mining, and other ' + "engineering equipment manufacturer. The company is the world's " + 'largest manufacturer of construction equipment. In 2018, Caterpillar ' + 'was ranked number 73 on the Fortune 500 list and number 265 on the ' + 'Global Fortune 500 list. Caterpillar stock is a component of the Dow ' + 'Jones Industrial Average. Caterpillar Inc. traces its origins to the ' + '1925 merger of the Holt Manufacturing Company and the C. L. Best ' + 'Tractor Company, creating a new entity, California-based Caterpillar ' + 'Tractor Company. In 1986, the company reorganized itself as a ' + 'Delaware corporation under the current name, Caterpillar Inc. It ' + 'announced in January 2017 that over the course of that year, it ' + 'would relocate its headquarters from Peoria, Illinois, to Deerfield, ' + 'Illinois, scrapping plans from 2015 of building an $800 million new ' + 'headquarters complex in downtown Peoria. Its headquarters are ' + 'located in Irving, Texas, since 2022. The company also licenses and ' + 'markets a line of clothing and workwear boots under its Cat / ' + 'Caterpillar name. Additionally, the company licensed the Cat phone ' + 'brand of toughened mobile phones and rugged smartphones from 2012 to ' + '2024.', + 'related': ['BA:NYSE', 'MU:NASDAQ', 'NVDA:NASDAQ']}, + 'CL:NYSE': {'about': {'CEO': 'Noel R. Wallace', + 'Employees': '33.6K', + 'Founded': '1806', + 'Headquarters': 'New York, New York, United States', + 'Sector': 'Household goods', + 'Website': 'colgatepalmolive.com'}, + 'logo_file': 'logos/CL.svg', + 'name': 'Colgate-Palmolive Co', + 'profile': 'The Colgate-Palmolive Company, commonly known as Colgate-Palmolive, ' + 'is an American multinational consumer products company headquartered ' + 'on Park Avenue in Midtown Manhattan, New York City. The company ' + 'specializes in the production, distribution, and provision of ' + 'household, health care, personal care, and veterinary products.', + 'related': ['PG:NYSE', 'KO:NYSE', 'JNJ:NYSE', 'WMT:NASDAQ', 'PEP:NASDAQ']}, + 'CMCSA:NASDAQ': {'about': {'CEO': 'Brian L. Roberts | Michael J. Cavanagh', + 'Employees': '179K', + 'Founded': 'Jun 28, 1963', + 'Headquarters': 'Philadelphia, Pennsylvania, United States', + 'Sector': 'Telecommunications'}, + 'logo_file': 'logos/CMCSA.svg', + 'name': 'Comcast Corp', + 'profile': 'Comcast Corporation, formerly known as Comcast Holdings, is an ' + 'American multinational mass media, telecommunications, and ' + "entertainment conglomerate. Comcast's corporate headquarters is " + 'at the Comcast Center in Philadelphia, while NBCUniversal, ' + "Comcast's New York operations and other major Comcast assets are " + 'headquartered at 30 Rockefeller Plaza in Midtown Manhattan in ' + 'New York City. The company was ranked 51st in the Forbes Global ' + '2000 in 2023. It is the fourth-largest telecommunications ' + 'company by worldwide revenue, after AT&T, Verizon, and China ' + 'Mobile. Comcast is the third-largest pay-TV company, the ' + 'second-largest cable TV company by subscribers, and the largest ' + 'home Internet service provider in the United States. It owns and ' + 'operates the Xfinity residential cable communications business ' + 'segment and division; Comcast Business, a commercial services ' + 'provider; and Xfinity Mobile, an MVNO of Verizon. The company is ' + "also the nation's third-largest home telephone service provider, " + 'serving residential and commercial customers in 40 states and ' + 'the District of Columbia. Comcast has owned NBCUniversal and its ' + 'various mass media subsidiaries since 2013.', + 'related': ['VZ:NYSE', 'AMZN:NASDAQ', 'ABT:NYSE', 'ORCL:NYSE']}, + 'COP:NYSE': {'about': {'Employees': '9.7K', + 'Founded': '1917', + 'Headquarters': 'Houston, Texas, United States', + 'Sector': 'Energy', + 'Website': 'conocophillips.com'}, + 'logo_file': 'logos/COP.svg', + 'name': 'ConocoPhillips', + 'profile': '', + 'related': ['CVX:NYSE', 'XOM:NYSE', 'OXY:NYSE', 'JNJ:NYSE', 'SLB:NYSE']}, + 'COST:NASDAQ': {'about': {'CEO': 'Ron Vachris', + 'Employees': '341K', + 'Headquarters': 'Issaquah, Washington, United States', + 'Sector': 'Consumer', + 'Website': 'costco.com'}, + 'logo_file': 'logos/COST.svg', + 'name': 'Costco Wholesale Corp', + 'profile': 'Costco Wholesale Corporation is an American multinational ' + 'corporation that operates a chain of membership-only big-box ' + 'warehouse club retail stores. As of 2021, Costco is the ' + 'third-largest retailer in the world, and as of August 2024, the ' + "world's largest retailer of beef, poultry, organic produce, and " + 'wine, with just under a third of American consumers regularly ' + 'shopping at Costco warehouses. As of 2025, Costco is ranked 12th ' + 'on the Fortune 500 rankings of the largest United States ' + 'corporations by total revenue, and is also ranked as the largest ' + "grocery retailer in Canada. Costco's worldwide headquarters are " + 'in Issaquah, Washington, an eastern suburb of Seattle. Its ' + 'Kirkland Signature house label is named after its former location ' + 'in Kirkland, another eastern Seattle suburb. The company opened ' + "its first warehouse in Seattle in 1983. Through mergers, Costco's " + 'corporate history dates back to 1976, when its former competitor ' + 'Price Club was founded in San Diego, California.', + 'related': ['WMT:NASDAQ', + 'MSFT:NASDAQ', + 'AMZN:NASDAQ', + 'AAPL:NASDAQ', + 'NVDA:NASDAQ']}, + 'CRM:NYSE': {'about': {'CEO': 'Marc Benioff', + 'Employees': '83.3K', + 'Founded': 'Feb 3, 1999', + 'Headquarters': 'San Francisco, California, United States', + 'Sector': 'Application software', + 'Website': 'salesforce.com'}, + 'logo_file': 'logos/CRM.svg', + 'name': 'Salesforce Inc', + 'profile': 'Salesforce, Inc., is an American cloud-based software company ' + 'headquartered in San Francisco, California. It is primarily known ' + 'for its customer relationship management software and related ' + 'applications which it delivers through a software as a service ' + 'subscription business model. It also provides software for sales, ' + 'customer service, marketing automation, e-commerce, analytics, ' + 'artificial intelligence, agentic AI, and application development. ' + 'Founded by former Oracle executive Marc Benioff in March 1999, ' + 'Salesforce grew quickly, making its initial public offering in 2004. ' + 'Its major acquisitions included Tableau and Slack. As of March 2026, ' + 'Salesforce has a market capitalization of approximately US$186 ' + 'billion. For fiscal year 2026, the company reported record annual ' + 'revenue of $41.5 billion. The company is a component of the Dow ' + 'Jones Industrial Average and the S&P 500.', + 'related': ['NOW:NYSE', 'MSFT:NASDAQ']}, + 'CSCO:NASDAQ': {'about': {'CEO': 'Chuck Robbins', + 'Employees': '86.2K', + 'Founded': 'Dec 10, 1984', + 'Headquarters': 'San Jose, California, United States', + 'Sector': 'Computer hardware', + 'Website': 'cisco.com'}, + 'logo_file': 'logos/CSCO.svg', + 'name': 'Cisco Systems Inc', + 'profile': 'Cisco Systems, Inc., doing business as Cisco, is an American ' + 'multinational technology conglomerate corporation that develops, ' + 'manufactures, and sells hardware, software, telecommunications ' + 'equipment and other high-technology services and products focused ' + 'on networking, cyber security and AI. Cisco specializes in ' + 'specific tech markets, such as the Internet of things, domain ' + 'security, videoconferencing, and energy management, including ' + 'products such as Webex, OpenDNS, Jabber, and Jasper. The company ' + 'is headquartered in San Jose, California, and, as of June 29, ' + '2026, has a market capitalization of $461 billion. Cisco Systems ' + 'was founded in December 1984 by Leonard Bosack and Sandy Lerner, ' + 'two Stanford University computer scientists. They pioneered the ' + 'concept of a local area network being used to connect distant ' + 'computers over a multiprotocol router system. The company went ' + 'public in 1990 and, by the end of the dot-com bubble in 2000, had ' + 'a market capitalization of $500 billion, surpassing Microsoft as ' + "the world's most valuable company, prior to losing 80 percent of " + 'its value by 2002, as the market bubble burst.', + 'related': ['AAPL:NASDAQ', + 'AMZN:NASDAQ', + 'CVX:NYSE', + 'TSLA:NASDAQ', + 'NFLX:NASDAQ']}, + 'CVS:NYSE': {'about': {'CEO': 'David Joyner', + 'Employees': '260K', + 'Founded': '1963', + 'Headquarters': 'Woonsocket, Rhode Island, United States', + 'Sector': 'Health care provider', + 'Website': 'cvshealth.com'}, + 'logo_file': 'logos/CVS.svg', + 'name': 'CVS Health Corp', + 'profile': 'CVS Health Corporation is an American multinational healthcare ' + 'company that owns CVS Pharmacy, a retail pharmacy chain; CVS ' + 'Caremark, a pharmacy benefits manager; and Aetna, a health insurance ' + "provider, among many other brands. The company is the world's second " + 'largest healthcare company, behind UnitedHealth Group. In 2023, the ' + 'company was ranked 64th in the Forbes Global 2000. CVS started in ' + 'Lowell, Massachusetts by brothers Stanley and Sidney Goldstein and ' + 'their partner Ralph Hoagland. The name stood for Consumer Value ' + 'Stores.', + 'related': ['UNH:NYSE', 'T:NYSE', 'BAC:NYSE']}, + 'CVX:NYSE': {'about': {'CEO': 'Mike Wirth', + 'Employees': '43K', + 'Founded': 'Sep 10, 1879', + 'Sector': 'Energy', + 'Website': 'chevron.com'}, + 'logo_file': 'logos/CVX.svg', + 'name': 'Chevron Corp', + 'profile': 'Chevron Corporation is an American multinational energy corporation ' + 'predominantly specializing in oil and gas. The second-largest direct ' + 'descendant of Standard Oil, and originally known as the Standard Oil ' + 'Company of California, it is active in more than 180 countries. ' + 'Founded originally in Southern California during the 1870s, after ' + 'the breakup of Standard Oil, Socal grew quickly on its own by ' + 'continuing to acquire companies and partnering with others both ' + 'inside and outside of California, eventually becoming one of the Big ' + 'Oil companies that dominated the global petroleum industry from the ' + 'mid-1940s to the 1970s. In 1985, Socal merged with the ' + 'Pittsburgh-based Gulf Oil and rebranded as Chevron; the newly merged ' + 'company later merged with Texaco in 2001. Chevron is one of the ' + 'largest companies in the world and the second-largest oil company ' + 'based in the United States by revenue, only behind fellow Standard ' + 'Oil descendant ExxonMobil. Within oil and gas, Chevron is vertically ' + 'integrated and is involved in hydrocarbon exploration, production, ' + 'refining, marketing and transport, chemicals manufacturing and ' + 'sales, and power generation.', + 'related': ['XOM:NYSE', + 'AAPL:NASDAQ', + 'COP:NYSE', + 'VOO:NYSEARCA', + 'TSLA:NASDAQ', + 'CSCO:NASDAQ']}, + 'D:NYSE': {'about': {'CEO': 'Robert M. Blue', + 'Employees': '15.2K', + 'Founded': '1983', + 'Sector': 'Multi-utility', + 'Website': 'dominionenergy.com'}, + 'logo_file': 'logos/D.svg', + 'name': 'Dominion Energy Inc', + 'profile': 'Dominion Energy, Inc., commonly referred to as Dominion, is an ' + 'American energy company headquartered in Richmond, Virginia that ' + 'supplies electricity in parts of Virginia, North Carolina, and South ' + 'Carolina and supplies natural gas to parts of Utah, Idaho and Wyoming, ' + 'West Virginia, Ohio, Pennsylvania, North Carolina, South Carolina, and ' + 'Georgia. Dominion also has generation facilities in Indiana, Illinois, ' + 'Connecticut, and Rhode Island. The company acquired Questar ' + 'Corporation in the Western United States, including parts of Utah and ' + 'Wyoming, in September 2016. In January 2019, Dominion Energy completed ' + 'its acquisition of SCANA Corporation.', + 'related': ['DUK:NYSE', 'SO:NYSE', 'NEE:NYSE', 'T:NYSE']}, + 'DE:NYSE': {'about': {'CEO': 'John C. May', + 'Employees': '73.1K', + 'Founded': '1837', + 'Headquarters': 'Moline, Illinois, United States', + 'Sector': 'Machine industry', + 'Website': 'deere.com'}, + 'logo_file': 'logos/DE.svg', + 'name': 'Deere & Co', + 'profile': 'Deere & Company, is an American corporation that manufactures ' + 'agricultural machinery, heavy equipment, forestry machinery, diesel ' + 'engines, drivetrains used in heavy equipment and lawn care equipment. ' + 'It also provides financial services and other related activities. ' + 'Deere & Company is listed on the New York Stock Exchange under the ' + 'symbol DE. The company\'s slogan is "Nothing Runs Like a Deere", and ' + 'its logo is a leaping deer with the words "John Deere". It has used ' + 'logos incorporating a leaping deer for over 155 years. It is ' + 'headquartered in Moline, Illinois. It ranked No. 84 in the 2022 ' + 'Fortune 500 list of the largest United States corporations. Its ' + 'tractor series include D series, E series, Specialty Tractors, Super ' + 'Heavy Duty Tractors, and JDLink.', + 'related': ['CAT:NYSE', 'LMT:NYSE']}, + 'DHR:NYSE': {'about': {'CEO': 'Rainer M. Blair', + 'Employees': '59K', + 'Founded': '1969', + 'Sector': 'Health care', + 'Website': 'danaher.com'}, + 'logo_file': 'logos/DHR.svg', + 'name': 'Danaher Corp', + 'profile': 'Danaher Corporation is an American healthcare company headquartered ' + 'in Washington, D.C.. It develops products used for advances in ' + 'biotechnology, life sciences, and diagnostics. The company operates ' + 'three divisions: biotechnology, which develops products for the ' + 'development of therapeutics; life sciences, which develops products ' + 'to identify causes of disease, new therapies, and to test and ' + 'manufacture new drugs, vaccines and gene editing technologies; and ' + 'diagnostics, which develops instruments, consumables, and software ' + 'and services to diagnose diseases. Danaher was founded in 1984 by ' + 'brothers Steven Rales and Mitchell Rales; it was named after Danaher ' + 'Creek in Western Montana, where the brothers came up with the idea ' + 'for the company while fishing. The company is ranked 180th on the ' + 'Fortune 500 and 241st on the Forbes Global 2000.', + 'related': ['TMO:NYSE', 'GOOGL:NASDAQ', 'NFLX:NASDAQ']}, + 'DIS:NYSE': {'about': {'CEO': "Josh D'Amaro", + 'Employees': '194K', + 'Founded': 'Oct 16, 1923', + 'Headquarters': 'Burbank, California, United States', + 'Sector': 'Entertainment', + 'Website': 'thewaltdisneycompany.com'}, + 'logo_file': 'logos/DIS.svg', + 'name': 'Walt Disney Co', + 'profile': 'The Walt Disney Company, commonly and globally known as simply ' + 'Disney, is an American multinational mass media and entertainment ' + 'conglomerate headquartered at the Walt Disney Studios complex in ' + 'Burbank, California. Founded on October 16, 1923, as an animation ' + 'studio by brothers Walt and Roy Oliver Disney as Disney Brothers ' + 'Cartoon Studio, Disney operated under the names Walt Disney Studio ' + 'and Walt Disney Productions before adopting its current name in ' + '1986. In 1928, Disney established itself as a leader in the ' + 'animation industry with the short film Steamboat Willie. The film ' + 'used synchronized sound to become the first post-produced sound ' + "cartoon, and popularized Mickey Mouse, who became Disney's mascot " + 'and corporate icon. After becoming a success by the early 1940s, ' + 'Disney diversified into live-action films, television, and theme ' + "parks in the 1950s. However, following Walt Disney's death in 1966, " + "the company's profits, especially in the animation sector, began to " + 'decline.', + 'related': ['PSX:NYSE', + 'AMZN:NASDAQ', + 'VZ:NYSE', + 'AAPL:NASDAQ', + 'NVDA:NASDAQ', + 'GM:NYSE']}, + 'DOW:NYSE': {'about': {'CEO': 'Karen S. Carter', + 'Employees': '34.6K', + 'Founded': 'Apr 1, 2019', + 'Headquarters': 'Midland, Michigan, United States', + 'Sector': 'Commodity chemical', + 'Website': 'dow.com'}, + 'logo_file': 'logos/DOW.svg', + 'name': 'Dow Inc', + 'profile': '', + 'related': ['PFE:NYSE', 'ABT:NYSE', 'NKE:NYSE']}, + 'DUK:NYSE': {'about': {'CEO': 'Harry K. Sideris', + 'Employees': '26.4K', + 'Founded': 'Apr 30, 1904', + 'Headquarters': 'Charlotte, North Carolina, United States', + 'Sector': 'Public utility', + 'Website': 'duke-energy.com'}, + 'logo_file': 'logos/DUK.svg', + 'name': 'Duke Energy Corp', + 'profile': 'Duke Energy Corporation is an American electric power and natural ' + 'gas holding company headquartered in Charlotte, North Carolina. The ' + 'company serves over 7 million customers in the eastern United ' + 'States. In 2024, it ranked as the 141st largest company in the ' + 'United States, its highest-ever placement on the Fortune 500 list.', + 'related': ['D:NYSE', 'SO:NYSE', 'VZ:NYSE', 'NEE:NYSE', 'T:NYSE']}, + 'EA:NASDAQ': {'about': {'CEO': 'Andrew Wilson', + 'Employees': '14.6K', + 'Founded': 'May 27, 1982', + 'Headquarters': 'Redwood City, California, United States'}, + 'logo_file': 'logos/EA.svg', + 'name': 'Electronic Arts Inc', + 'profile': 'Electronic Arts Inc. is an American video game company ' + 'headquartered in Redwood City, California. Founded in May 1982 by ' + 'former Apple employee Trip Hawkins, the company was a pioneer of ' + 'the early home computer game industry and promoted the designers ' + 'and programmers responsible for its games as "software artists". EA ' + 'published numerous games and some productivity software for ' + 'personal computers, all of which were developed by external ' + "individuals or groups until 1987's Skate or Die! The company " + 'shifted toward internal game studios, often through acquisitions, ' + 'such as Distinctive Software becoming EA Canada in 1991. Into the ' + '21st century, EA develops and publishes games of established ' + 'franchises, including Army of Two, Battlefield, Command & Conquer, ' + 'Dragon Age, Dead Space, Mass Effect, Medal of Honor, Need for ' + 'Speed, Plants vs. Zombies, The Sims, Skate, SSX, and Star Wars, as ' + 'well as the EA Sports titles College Football, Dirt Rally, FC–FIFA, ' + 'Madden NFL, NASCAR, NBA Live, NHL, PGA, UFC, and WRC. Since 2022, ' + 'its desktop titles appear on the self-developed EA App, an online ' + 'gaming digital distribution platform for PCs and a direct ' + "competitor to Valve's Steam and Epic Games' Store.", + 'related': ['MSFT:NASDAQ', 'AAPL:NASDAQ', 'NVDA:NASDAQ']}, + 'ECL:NYSE': {'about': {'CEO': 'Christophe Beck', + 'Employees': '48K', + 'Founded': '1923', + 'Headquarters': 'Saint Paul, Minnesota, United States', + 'Sector': 'Speciality chemicals', + 'Website': 'ecolab.com'}, + 'logo_file': 'logos/ECL.svg', + 'name': 'Ecolab Inc', + 'profile': 'Ecolab Inc., headquartered in Saint Paul, Minnesota, provides ' + 'products related to water treatment, sanitation, and hygiene, ' + 'primarily to commercial and industrial customers. The company is ' + 'ranked 274th on the Fortune 500 and 569th on the Forbes Global 2000. ' + 'Wikipedia\n' + 'About Ecolab Inc\n' + 'CEO\n' + 'Christophe Beck\n' + 'Employees\n' + '48K\n' + 'Founded\n' + '1923\n' + 'Headquarters\n' + 'Saint Paul, Minnesota, United States\n' + 'Sector\n' + 'Speciality chemicals\n' + 'Website\n' + 'ecolab.com', + 'related': ['CAT:NYSE', 'DE:NYSE']}, + 'EOG:NYSE': {'about': {'CEO': 'Ezra Y. Yacob', + 'Employees': '3.4K', + 'Founded': '1999', + 'Headquarters': 'Houston, Texas, United States', + 'Sector': 'Energy', + 'Website': 'eogresources.com'}, + 'logo_file': 'logos/EOG.svg', + 'name': 'EOG Resources Inc', + 'profile': 'EOG Resources, Inc. is an American energy company engaged in ' + 'hydrocarbon exploration. It is organized in Delaware and ' + 'headquartered in the Heritage Plaza building in Houston, Texas. The ' + 'company is ranked 186th on the Fortune 500 and 337th on the Forbes ' + 'Global 2000. The company was named Enron Oil & Gas Company before ' + 'its separation from Enron in 1999.', + 'related': ['COP:NYSE', 'CVX:NYSE', 'XOM:NYSE']}, + 'EQIX:NASDAQ': {'about': {'CEO': 'Adaire Fox-Martin', + 'Employees': '13.7K', + 'Founded': 'Jun 22, 1998', + 'Headquarters': 'Redwood City, California, United States', + 'Sector': 'Real estate investment trust', + 'Website': 'equinix.com'}, + 'logo_file': 'logos/EQIX.svg', + 'name': 'Equinix Inc', + 'profile': 'Equinix Inc., is an American multinational internet and data ' + 'center company headquartered in Redwood City, California. It ' + 'specialized in internet connectivity and data center colocation ' + 'centers, commonly known as carrier hotels, until the company ' + 'converted to a real estate investment trust in January 2015. It ' + 'is listed on the Nasdaq stock exchange under the ticker symbol ' + 'EQIX. As of 2025, the company operates 260 data centers in 33 ' + 'countries, across five continents and employed more than 13,000 ' + 'people worldwide.', + 'related': ['PLD:NYSE', 'TSLA:NASDAQ']}, + 'EXC:NASDAQ': {'about': {'CEO': 'Calvin G. Butler Jr.', + 'Employees': '20.6K', + 'Founded': 'Oct 2000', + 'Headquarters': 'Chicago, Illinois, United States', + 'Sector': 'Public utility', + 'Website': 'exeloncorp.com'}, + 'logo_file': 'logos/EXC.svg', + 'name': 'Exelon Corp', + 'profile': 'Exelon Corporation is an American public utility headquartered in ' + 'Chicago, and incorporated in Pennsylvania. Exelon is the largest ' + 'electric parent company in the United States by revenue and is the ' + 'largest regulated electric utility in the United States with ' + 'approximately 10 million customers. The company is ranked 187th on ' + 'the Fortune 500. Exelon owns six regulated utilities: Atlantic ' + 'City Electric, Commonwealth Edison, PECO Energy Company, Baltimore ' + 'Gas and Electric, Delmarva Power and Light, and Pepco.', + 'related': ['DUK:NYSE', 'AEP:NASDAQ', 'NEE:NYSE', 'NVDA:NASDAQ']}, + 'F:NYSE': {'about': {'CEO': 'Jim Farley', + 'Employees': '169K', + 'Founded': 'Jun 16, 1903', + 'Headquarters': 'Dearborn, Michigan, United States', + 'Sector': 'Car'}, + 'logo_file': 'logos/F.svg', + 'name': 'Ford Motor Co', + 'profile': 'The Ford Motor Company is an American multinational automobile ' + 'manufacturer headquartered in Dearborn, Michigan, United States. It ' + 'was founded by Henry Ford and incorporated on June 16, 1903. The ' + 'company sells automobiles and commercial vehicles under the Ford ' + 'brand, and luxury cars under its Lincoln brand. The company is listed ' + 'on the New York Stock Exchange under the single-letter ticker symbol F ' + 'and is controlled by the Ford family. They have minority ownership but ' + 'a plurality of the voting power. Ford introduced methods for ' + 'large-scale manufacturing of cars and large-scale management of an ' + 'industrial workforce using elaborately engineered manufacturing ' + 'sequences typified by moving assembly lines. By 1914, these methods ' + "were known around the world as Fordism. Ford's former British " + 'subsidiaries Jaguar and Land Rover, acquired in 1989 and 2000, ' + 'respectively, were sold to the Indian automaker Tata Motors in March ' + '2008. Ford owned the Swedish automaker Volvo from 1999 to 2010. In the ' + 'third quarter of 2010, Ford discontinued the Mercury brand, under ' + 'which it had marketed middle priced cars in the United States, Canada, ' + 'Mexico, and the Middle East since 1938.', + 'related': ['BAC:NYSE', 'GM:NYSE']}, + 'FCX:NYSE': {'about': {'CEO': 'Kathleen L. Quirk', + 'Employees': '29K', + 'Founded': 'Nov 10, 1987', + 'Headquarters': 'Phoenix, Arizona, United States', + 'Sector': 'Copper', + 'Website': 'fcx.com'}, + 'logo_file': 'logos/FCX.svg', + 'name': 'Freeport-McMoRan Inc', + 'profile': 'Freeport-McMoRan Inc., often called Freeport, is an American mining ' + 'company based in the Freeport-McMoRan Center, in Phoenix, Arizona. ' + "The company is the world's largest producer of molybdenum, a major " + "copper producer and operates the world's largest gold mine, the " + 'Grasberg mine in Papua, Indonesia. It was founded 1912 by the son of ' + 'a Swedish banker to develop sulfur mining along the US Gulf Coast ' + 'concurrently with Freeport, Texas. In 1913, it incorporated as a ' + 'vertically integrated holding company including numerous ' + 'subsidiaries like gas and light, railroad, oil from Tampico, Mexico, ' + 'and a refinery. After 1930, the company diversified by buying into ' + 'manganese mines, nickel during World War II, potash and the Cuban ' + 'American Manganese Corporationin the 1950s. Since 1967, the company ' + 'has mined the Ertsberg deposit of gold and copper in the Grasberg ' + 'mine, where since 2017 it has been holding a 49% interest. In the ' + '1990s the Rio Tinto Group invested in Freeport and IMC Group ' + 'acquired its fertilizer business.', + 'related': ['NFLX:NASDAQ', 'TSLA:NASDAQ', 'OXY:NYSE']}, + 'GE:NYSE': {'about': {'CEO': 'Larry Culp', + 'Employees': '304K', + 'Founded': 'Apr 15, 1892', + 'Sector': 'Aerospace and Defense', + 'Website': 'ge.com'}, + 'logo_file': 'logos/GE.svg', + 'name': 'General Electric Co', + 'profile': 'General Electric Company was an American multinational conglomerate ' + 'founded in 1892, incorporated in the state of New York and ' + 'headquartered, during its final year of operation, in Boston. Over ' + 'the years, the company had multiple divisions, including aerospace, ' + 'transportation, energy, healthcare, lighting, locomotives, ' + 'appliances, and finance. In 2020, GE ranked among the Fortune 500 as ' + 'the 33rd largest firm in the United States by gross revenue. In 2023, ' + 'the company was ranked 64th in the Forbes Global 2000. In 2011, GE ' + 'ranked among the Fortune 20 as the 14th most profitable company, but ' + 'later very severely underperformed the market as its profitability ' + 'collapsed. Two employees of GE—Irving Langmuir and Ivar Giaever —have ' + 'been awarded the Nobel Prize. From 1986 until 2013, GE was the owner ' + 'of the NBC television network through its purchase of its former ' + "subsidiary RCA before its acquisition of NBC's parent company " + 'NBCUniversal by Comcast in 2011. Following the Great Recession of the ' + 'late 2000s, General Electric began selling off various divisions and ' + 'assets, including its appliances and financial capital divisions, ' + "under Jeff Immelt's leadership as CEO.", + 'related': ['BA:NYSE', 'RTX:NYSE']}, + 'GILD:NASDAQ': {'about': {'CEO': "Daniel O'Day", + 'Employees': '17K', + 'Founded': 'Jun 22, 1987', + 'Headquarters': 'Foster City, California, United States', + 'Sector': 'Biotechnology', + 'Website': 'gilead.com'}, + 'logo_file': 'logos/GILD.svg', + 'name': 'Gilead Sciences, Inc', + 'profile': 'Gilead Sciences, Inc. is an American biopharmaceutical company ' + 'headquartered in Foster City, California, that focuses on ' + 'researching and developing antiviral drugs used in the treatment ' + 'of HIV/AIDS, hepatitis B, hepatitis C, influenza, and COVID-19. ' + 'It also develops medicines in therapeutic areas such as oncology ' + 'and inflammation and cell therapy. In 1987, Michael L. Riordan ' + 'founded the company under the name Oligogen. The original name ' + 'was a reference to oligonucleotides, small strands of DNA used to ' + 'target genetic sequences. Gilead held its initial public offering ' + 'in 1992, and successfully developed drugs including Tamiflu and ' + 'Vistide that decade. In the 2000s, Gilead began evolving from a ' + 'biotechnology company into a large pharmaceutical company, ' + 'through acquisitions and investments, acquiring several ' + 'subsidiaries. Gilead is a member of the Nasdaq-100 and the S&P ' + '100. The company has faced criticism over its business practices, ' + 'including extremely high pricing of drugs such as Sovaldi and ' + 'Truvada in the United States relative to production cost and cost ' + 'in the developing world.', + 'related': ['AXP:NYSE', + 'OXY:NYSE', + 'AMGN:NASDAQ', + 'ABBV:NYSE', + 'BAC:NYSE', + 'JNJ:NYSE']}, + 'GIS:NYSE': {'about': {'CEO': 'Jeff Harmening', + 'Employees': '30K', + 'Founded': 'Jun 20, 1928', + 'Headquarters': 'Minneapolis, Minnesota, United States', + 'Sector': 'Food', + 'Website': 'generalmills.com'}, + 'logo_file': 'logos/GIS.svg', + 'name': 'General Mills Inc', + 'profile': 'General Mills, Inc. is an American multinational manufacturer and ' + 'marketer of branded consumer foods sold through retail stores. ' + 'Founded on the banks of the Mississippi River at Saint Anthony Falls ' + 'in Minneapolis, the company originally gained fame for being a large ' + 'flour miller. It is headquartered in Golden Valley, Minnesota, a ' + 'suburb of Minneapolis. Today, the company markets many well-known ' + "North American brands, including Gold Medal flour, Annie's " + 'Homegrown, Lärabar, Cascadian Farm, Betty Crocker, Nature Valley, ' + "Totino's, Pillsbury, Old El Paso, Häagen-Dazs, as well as breakfast " + 'cereals under the General Mills name, including Cheerios, Wheaties, ' + 'Chex, Lucky Charms, Trix, Cocoa Puffs, and the monster cereals.', + 'related': ['QCOM:NASDAQ', 'VZ:NYSE']}, + 'GM:NYSE': {'about': {'CEO': 'Mary Barra', + 'Employees': '156K', + 'Founded': 'Sep 16, 1908', + 'Sector': 'Car', + 'Website': 'gm.com'}, + 'logo_file': 'logos/GM.svg', + 'name': 'General Motors Co', + 'profile': 'General Motors Company is an American multinational automotive ' + 'manufacturing company headquartered in Detroit, Michigan, United ' + 'States. The company is most known for owning and manufacturing four ' + 'automobile brands: Chevrolet, Buick, GMC, and Cadillac, each a ' + 'separate division of GM. By total sales, it has continuously been the ' + 'largest automaker in the United States, and was the largest in the ' + 'world for 77 years before losing the title to Toyota in 2008. General ' + 'Motors operates manufacturing plants in eight countries. In addition ' + 'to its four core brands, GM also holds interests in Chinese brands ' + 'Baojun and Wuling via SAIC-GM-Wuling Automobile. GM further owns a ' + 'namesake defense vehicles division which produces military vehicles ' + 'for the United States government and military, the vehicle safety, ' + 'security, and information services provider OnStar, the auto parts ' + 'company ACDelco, and a namesake financial lending service. The ' + 'company originated as a holding company for Buick established on ' + 'September 16, 1908, by William C. Durant. The current entity was ' + 'established in 2009 after the General Motors Chapter 11 ' + 'reorganization.', + 'related': ['F:NYSE', 'DIS:NYSE', 'TSLA:NASDAQ']}, + 'GOOGL:NASDAQ': {'about': {'CEO': 'Sundar Pichai', + 'Employees': '199K', + 'Founded': 'Oct 2, 2015', + 'Headquarters': 'Mountain View, California, United States', + 'Sector': 'Interactive media', + 'Website': 'abc.xyz'}, + 'logo_file': 'logos/GOOGL.svg', + 'name': 'Alphabet Inc Class A', + 'profile': 'Alphabet Inc. is an American multinational technology ' + 'conglomerate holding company headquartered in Mountain View, ' + 'California. It was created through a restructuring of Google on ' + 'October 2, 2015, and became the parent holding company of Google ' + 'and several former Google subsidiaries. Alphabet is listed on ' + 'the large-cap section of the Nasdaq under the ticker symbols ' + 'GOOGL and GOOG; both classes of stock are components of major ' + 'stock market indices such as the S&P 500 and Nasdaq-100. ' + 'Alphabet has been described as a Big Tech company. The ' + 'establishment of Alphabet Inc. was prompted by a desire to make ' + 'the core Google business "cleaner and more accountable" while ' + 'allowing greater autonomy to group companies that operate in ' + 'businesses other than Internet services. Founders Larry Page and ' + 'Sergey Brin announced their resignation from their executive ' + 'posts in December 2019, with the CEO role to be filled by Sundar ' + 'Pichai, who is also the CEO of Google. Page and Brin remain ' + 'employees, board members, and controlling shareholders of ' + 'Alphabet Inc.', + 'related': ['AMZN:NASDAQ', + 'MSFT:NASDAQ', + 'AAPL:NASDAQ', + 'NVDA:NASDAQ', + 'AVGO:NASDAQ', + 'MU:NASDAQ']}, + 'GS:NYSE': {'about': {'Employees': '46.2K', + 'Founded': '1869', + 'Headquarters': 'New York, New York, United States', + 'Sector': 'Capital market', + 'Website': 'goldmansachs.com'}, + 'logo_file': 'logos/GS.svg', + 'name': 'Goldman Sachs Group Inc', + 'profile': '', + 'related': ['VZ:NYSE', 'JPM:NYSE', 'MS:NYSE', 'SPY:NYSEARCA']}, + 'HD:NYSE': {'about': {'CEO': 'Edward Decker', + 'Employees': '472K', + 'Founded': 'Feb 6, 1978', + 'Headquarters': 'Atlanta, Georgia, United States', + 'Sector': 'Home improvement', + 'Website': 'homedepot.com'}, + 'logo_file': 'logos/HD.svg', + 'name': 'Home Depot Inc', + 'profile': 'The Home Depot, Inc. is an American multinational home improvement ' + 'retail corporation which sells tools, construction products, ' + 'appliances, and services including fuel and transportation rentals. ' + 'Home Depot is the largest home improvement retailer in the United ' + 'States. In fiscal 2024, the company reported $159.5 billion in ' + 'revenue and approximately 470,100 associates. The company is ' + 'headquartered in Cobb County, Georgia, with an Atlanta mailing ' + 'address. Home Depot operates many big-box format stores across the ' + 'United States; all 10 provinces of Canada; and all 31 Mexican states ' + 'and Mexico City. Interline Brands, a maintenance, repair, and ' + 'operations company is also owned by The Home Depot, with 70 ' + 'distribution centers across the United States. It is the seventh ' + 'largest United States–based employer globally.', + 'related': ['KO:NYSE', 'LOW:NYSE', 'MCD:NYSE', 'WMT:NASDAQ', 'JNJ:NYSE']}, + 'HON:NASDAQ': {'about': {'CEO': 'Vimal Kapur', + 'Employees': '101K', + 'Founded': '1906', + 'Headquarters': 'Charlotte, North Carolina, United States', + 'Sector': 'Conglomerate', + 'Website': 'honeywell.com'}, + 'logo_file': 'logos/HON.svg', + 'name': 'Honeywell International Inc', + 'profile': 'Honeywell International Inc. is an American publicly traded, ' + 'multinational conglomerate corporation headquartered in Charlotte, ' + 'North Carolina. It primarily operates in four areas of business: ' + 'aerospace, building automation, industrial automation, and energy ' + 'and sustainability solutions. Honeywell also operates Sandia ' + 'National Laboratories under contract with the U.S. Department of ' + 'Energy. Honeywell is a Fortune 500 company, ranked 115th in 2023. ' + 'In 2025, the corporation had a global workforce of approximately ' + '101,000 employees. As of 2025, its chairman and chief executive ' + "officer was Vimal Kapur. The corporation's name, Honeywell " + 'International Inc., is a product of the merger of Honeywell Inc. ' + 'and AlliedSignal in 1999. The corporation headquarters were ' + "consolidated with AlliedSignal's headquarters in Morristown, New " + 'Jersey. The combined company chose the name "Honeywell" because of ' + 'the considerable brand recognition. Honeywell was a component of ' + 'the Dow Jones Industrial Average index from 1999 to 2008. Prior to ' + '1999, its corporate predecessors were included dating back to ' + '1925, including early entrants in the computing and thermostat ' + 'industries.', + 'related': ['AMZN:NASDAQ', 'GOOGL:NASDAQ', 'HD:NYSE', 'MSFT:NASDAQ']}, + 'IBM:NYSE': {'about': {'CEO': 'Arvind Krishna', + 'Employees': '264K', + 'Founded': 'Jun 16, 1911', + 'Headquarters': 'Armonk, New York, United States', + 'Sector': 'Information technology consulting', + 'Website': 'ibm.com'}, + 'logo_file': 'logos/IBM.svg', + 'name': 'IBM Common Stock', + 'profile': 'International Business Machines Corporation, doing business as IBM, ' + 'is an American multinational technology company headquartered in ' + 'Armonk, New York, and present in over 175 countries. It is a ' + 'publicly traded company and one of the 30 companies in the Dow Jones ' + 'Industrial Average. IBM is the largest industrial research ' + 'organization in the world, with 19 research facilities across a ' + 'dozen countries; for 29 consecutive years, from 1993 to 2021, it ' + 'held the record for most annual U.S. patents generated by a ' + 'business. IBM was founded in 1911 as the ' + 'Computing-Tabulating-Recording Company, a holding company of ' + 'manufacturers of record-keeping and measuring systems. It was ' + 'renamed "International Business Machines" in 1924 and soon became ' + 'the leading manufacturer of punch-card tabulating systems. During ' + 'the 1960s and 1970s, the IBM mainframe, exemplified by the ' + "System/360 and its successors, was the world's dominant computing " + 'platform, with the company producing 80 percent of computers in the ' + 'U.S. and 70 percent of computers worldwide.', + 'related': ['MSFT:NASDAQ', 'NVDA:NASDAQ', 'INTC:NASDAQ', 'AMZN:NASDAQ']}, + 'INTC:NASDAQ': {'about': {'CEO': 'Lip-Bu Tan', + 'Employees': '82.3K', + 'Founded': 'Jul 18, 1968', + 'Headquarters': 'Santa Clara, California, United States', + 'Sector': 'Semiconductor', + 'Website': 'intel.com'}, + 'logo_file': 'logos/INTC.svg', + 'name': 'Intel Corp', + 'profile': 'Intel Corporation is an American multinational technology company ' + 'headquartered in Santa Clara, California. It designs, ' + 'manufactures, and sells computer components such as central ' + 'processing units and related products for business and consumer ' + "markets. Intel was the world's third-largest semiconductor chip " + 'manufacturer by revenue in 2024 and has been included in the ' + 'Fortune 500 list of the largest United States corporations by ' + 'revenue since 2007. It was one of the first companies listed on ' + 'Nasdaq. Intel supplies microprocessors for most manufacturers of ' + 'computer systems, and is one of the developers of the x86 series ' + 'of instruction sets found in most personal computers. It also ' + 'manufactures chipsets, network interface controllers, flash ' + 'memory, graphics processing units, and other devices related to ' + 'communications and computing. Intel has a strong presence in the ' + 'general-purpose and gaming PC market with its Intel Core line of ' + 'CPUs and Intel Arc series of GPUs. Intel was founded in 1968 by ' + 'engineers Gordon Moore and Robert Noyce, along with investor ' + 'Arthur Rock, and is associated with the executive leadership and ' + 'vision of Andrew Grove.', + 'related': ['NVDA:NASDAQ', + 'MU:NASDAQ', + 'AMD:NASDAQ', + 'MSFT:NASDAQ', + 'TSLA:NASDAQ']}, + 'INTU:NASDAQ': {'about': {'CEO': 'Sasan K. Goodarzi', + 'Employees': '18.2K', + 'Founded': '1983', + 'Headquarters': 'Mountain View, California, United States', + 'Sector': 'Application software', + 'Website': 'intuit.com'}, + 'logo_file': 'logos/INTU.svg', + 'name': 'Intuit Inc', + 'profile': 'Intuit Inc. is an American multinational business software ' + 'company that specializes in financial software. Headquartered in ' + 'Mountain View, California, the company is led by CEO Sasan ' + "Goodarzi. Intuit's products include the tax preparation " + 'application TurboTax, the small business accounting software ' + 'QuickBooks, the credit monitoring and personal finance service ' + 'Credit Karma, and the email marketing platform Mailchimp. As of ' + '2019, more than 95% of its revenue and earnings originated from ' + 'its operations within the United States. Intuit is listed on the ' + 'Nasdaq stock exchange and is a component of the Nasdaq-100, S&P ' + "100, and S&P 500 stock market indices. Intuit offered 'TurboTax " + "Free File' alongside a similarly named but paid service, " + "'TurboTax Free Edition'. In 2019, investigations by ProPublica " + 'found that Intuit deliberately steered taxpayers from the free ' + 'TurboTax Free File to the paid TurboTax Free Edition using ' + 'tactics including search engine delisting and a deceptive ' + 'discount targeted to members of the military. As of the 2021 tax ' + 'filing season, TurboTax no longer participates in the Free File ' + 'Alliance.', + 'related': ['NVDA:NASDAQ', 'CRM:NYSE', 'AMZN:NASDAQ', 'TSLA:NASDAQ']}, + 'IVV:NYSEARCA': {'about': {}, + 'logo_file': 'logos/IVV.svg', + 'name': 'iShares Core S&P 500 ETF', + 'profile': '', + 'related': ['VOO:NYSEARCA', 'QQQ:NASDAQ', 'SPY:NYSEARCA']}, + 'IWM:NYSEARCA': {'about': {}, + 'logo_file': 'logos/IWM.svg', + 'name': 'iShares Russell 2000 ETF', + 'profile': '', + 'related': ['QQQ:NASDAQ', 'SPY:NYSEARCA']}, + 'JNJ:NYSE': {'about': {'CEO': 'Joaquin Duato', + 'Employees': '138K', + 'Founded': 'Jan 1886', + 'Headquarters': 'New Brunswick, New Jersey, United States', + 'Sector': 'Pharmaceutical industry', + 'Website': 'jnj.com'}, + 'logo_file': 'logos/JNJ.svg', + 'name': 'Johnson & Johnson', + 'profile': 'Johnson & Johnson is an American multinational pharmaceutical, ' + 'biotechnology, and medical technologies corporation headquartered in ' + 'New Brunswick, New Jersey. The company is ranked No. 48 on the 2025 ' + 'Fortune 500 list of the largest United States corporations. In 2025, ' + 'the company was ranked 42nd in the Forbes Global 2000. Along with ' + 'Microsoft, Johnson & Johnson is one of only two U.S.-based companies ' + 'that has a prime credit rating of AAA from S&P Global.', + 'related': ['PG:NYSE', + 'KO:NYSE', + 'JPM:NYSE', + 'ABBV:NYSE', + 'NVDA:NASDAQ', + 'WMT:NASDAQ']}, + 'JPM:NYSE': {'about': {'CEO': 'Jamie Dimon', + 'Employees': '321K', + 'Founded': 'Dec 31, 2000', + 'Headquarters': 'New York, New York, United States', + 'Sector': 'Capital market', + 'Website': 'jpmorganchase.com'}, + 'logo_file': 'logos/JPM.svg', + 'name': 'JPMorgan Chase & Co', + 'profile': 'JPMorgan Chase & Co. is an American multinational banking ' + 'institution headquartered in New York City and incorporated in ' + 'Delaware. It is the largest bank in the United States, and the ' + "world's largest bank by market capitalization as of 2026. As the " + 'largest of the Big Four banks in America, the firm is considered ' + 'systemically important by the Financial Stability Board. Its size ' + 'and scale have often led to enhanced regulatory oversight as well as ' + 'the development of an internal "Fortress Balance Sheet". JPMorgan ' + 'Chase is located at 270 Park Avenue in Midtown Manhattan. It was ' + 'created in 2000 by the merger of New York City banks J.P. Morgan & ' + 'Co. and Chase Manhattan Company. Through its predecessors, the ' + "firm's early history can be traced to 1799, with the founding of " + 'what became the Bank of the Manhattan Company. J.P. Morgan & Co. was ' + 'founded in 1871 by the American financier J. P. Morgan, who launched ' + 'the House of Morgan on 23 Wall Street as a national purveyor of ' + 'commercial, investment, and private banking services. Today, the ' + 'firm is a major provider of investment banking services, through ' + 'corporate advisory, mergers and acquisitions, sales and trading, and ' + 'public offerings.', + 'related': ['AXP:NYSE', 'BAC:NYSE', 'C:NYSE', 'AAPL:NASDAQ']}, + 'KMB:NASDAQ': {'about': {'CEO': 'Michael D. Hsu', + 'Employees': '36K', + 'Founded': '1872', + 'Headquarters': 'Irving, Texas, United States', + 'Sector': 'Household goods', + 'Website': 'kimberly-clark.com'}, + 'logo_file': 'logos/KMB.svg', + 'name': 'Kimberly-Clark Corp', + 'profile': 'Kimberly-Clark Corporation is an American multinational consumer ' + 'goods and personal care corporation that produces mostly ' + 'paper-based consumer products. The company manufactures sanitary ' + 'paper products and surgical & medical instruments. Kimberly-Clark ' + 'brand name products include Kleenex facial tissue, Kotex feminine ' + 'hygiene products, Cottonelle, Scott and Andrex toilet paper, ' + 'Wypall utility wipes, KimWipes scientific cleaning wipes and ' + 'Huggies disposable diapers and baby wipes. Founded in Neenah, ' + 'Wisconsin, in 1872 and based in the Las Colinas section of Irving, ' + 'Texas, since 1985, the company operated its own paper mills around ' + 'the world for decades, but closed the last of those in 2012. With ' + 'recent annual revenues topping $18 billion per year, ' + 'Kimberly-Clark is regularly listed among the Fortune 500. As of ' + 'March 2020, the company had approximately 40,000 employees.', + 'related': ['VZ:NYSE', 'WMT:NASDAQ', 'XOM:NYSE']}, + 'KO:NYSE': {'about': {'CEO': 'Henrique Braun | Henrique Braun', + 'Employees': '65.9K', + 'Founded': '1892', + 'Headquarters': 'Atlanta, Georgia, United States', + 'Sector': 'Drink', + 'Website': 'coca-colacompany.com'}, + 'logo_file': 'logos/KO.svg', + 'name': 'Coca-Cola Co', + 'profile': 'The Coca-Cola Company is an American multinational corporation ' + 'founded in January 1892, headquartered in Atlanta, Georgia. It ' + 'manufactures, sells and markets soft drinks including Coca-Cola, ' + 'other non-alcoholic beverage concentrates and syrups, and alcoholic ' + 'beverages. Its stock is listed on the New York Stock Exchange and is ' + 'a component of the DJIA and the S&P 500 and S&P 100 indices. ' + 'Coca-Cola was developed in May 1886 by pharmacist John Stith ' + 'Pemberton. At the time it was introduced, the product contained the ' + 'stimulants cocaine from coca leaves and caffeine from kola nuts, ' + 'which together acted synergistically. The coca and the kola are the ' + "source of the product name and led to Coca-Cola's promotion as a " + '"healthy tonic". Pemberton had been severely wounded in the American ' + 'Civil War and had become addicted to the pain medication morphine. At ' + 'the time, cocaine was being promoted as a "cure" for opioid ' + 'addiction, so he developed the beverage as a patent medicine in an ' + 'effort to control his addiction. In 1889, the formula and brand were ' + 'sold for $2,300 to Asa Griggs Candler, who incorporated the Coca-Cola ' + 'Company in Atlanta in 1892.', + 'related': ['JNJ:NYSE', 'HD:NYSE']}, + 'LIN:NASDAQ': {'about': {'Employees': '65K', + 'Founded': '1879', + 'Sector': 'Industrial gas', + 'Website': 'linde.com'}, + 'logo_file': 'logos/LIN.svg', + 'name': 'Linde PLC', + 'profile': "Linde is a global multinational chemical company and the world's " + 'largest industrial gas supplier by market share and revenue. ' + 'Founded by German scientist and engineer Carl von Linde in 1879 in ' + 'Wiesbaden, Germany, the company is now headquartered in Woking, ' + 'United Kingdom, and registered in Ireland as Linde plc. Linde plc ' + 'was formed in 2018 through the merger of Linde AG and Praxair, ' + 'which was founded in 1907 in the United States as Linde Air ' + "Products Company. The company's primary business is the " + 'manufacturing and distribution of atmospheric gases, including ' + 'oxygen, nitrogen, argon, rare gases, and process gases, including ' + 'carbon dioxide, helium, hydrogen, ammonia, electronic gases, ' + "specialty gases, and acetylene. Linde's products are used in the " + 'healthcare, petroleum refining, manufacturing, food, beverage ' + 'carbonation, fiber-optics, steel making, aerospace, material ' + 'handling equipment, chemicals, electronics and water treatment ' + 'industries. Linde is ranked 463rd on the Fortune Global 500 and ' + '187th on the Forbes Global 2000.', + 'related': ['AAPL:NASDAQ', + 'APD:NYSE', + 'NVDA:NASDAQ', + 'AVGO:NASDAQ', + 'INTC:NASDAQ']}, + 'LLY:NYSE': {'about': {'CEO': 'David A. Ricks', + 'Employees': '50K', + 'Founded': '1876', + 'Headquarters': 'Indianapolis, Indiana, United States', + 'Sector': 'Pharmaceutical industry', + 'Website': 'lilly.com'}, + 'logo_file': 'logos/LLY.svg', + 'name': 'Eli Lilly And Co', + 'profile': 'Eli Lilly and Company, doing business as Lilly, is an American ' + 'multinational pharmaceutical company headquartered in Indianapolis, ' + 'Indiana, with offices in 18 countries. Its products are sold in ' + 'approximately 125 countries. The company was founded in 1876 by Eli ' + 'Lilly, a pharmaceutical chemist and Union army veteran during the ' + 'American Civil War for whom the company was later named. The company ' + 'is ranked 4th on the list of largest biomedical companies by revenue ' + 'and is the most valuable pharmaceutical company worldwide. It is ' + 'ranked 100th on the Fortune 500 and 138th on the Forbes Global 2000. ' + 'In November 2025, the company reached a $1 trillion market ' + 'capitalization, the first health-care company in the world to do so. ' + "The company's primary products are tirzepatide for the treatment of " + 'type 2 diabetes, weight loss, and obstructive sleep apnea; ' + 'abemaciclib for the treatment of advanced or metastatic breast ' + 'cancers; dulaglutide for the treatment of type 2 diabetes; ' + 'ixekizumab for the treatment of autoimmune diseases; and ' + 'empagliflozin for the treatment of type 2 diabetes.', + 'related': ['MSFT:NASDAQ', + 'AAPL:NASDAQ', + 'TSLA:NASDAQ', + 'AMZN:NASDAQ', + 'AVGO:NASDAQ', + 'MU:NASDAQ']}, + 'LMT:NYSE': {'about': {'CEO': 'James Taiclet', + 'Employees': '123K', + 'Founded': '1995', + 'Headquarters': 'Bethesda, Maryland, United States', + 'Sector': 'Aerospace and Defense', + 'Website': 'lockheedmartin.com'}, + 'logo_file': 'logos/LMT.svg', + 'name': 'Lockheed Martin Corp', + 'profile': 'The Lockheed Martin Corporation is an American defense and aerospace ' + 'manufacturer. It is headquartered in North Bethesda, Maryland, ' + 'United States. The company was formed by the merger of the Lockheed ' + 'Corporation with Martin Marietta on March 15, 1995. Lockheed Martin ' + 'operates 4 divisions: Lockheed Martin Aeronautics; Lockheed Martin ' + 'Missiles and Fire Control; Lockheed Martin Rotary and Mission ' + 'Systems; and Lockheed Martin Space. As of 2023, it is the top ' + 'contractor of the United States federal government, and has been ' + "since at least 2008. As of 2024, 73% of the company's revenue came " + 'from the federal government, including 65% from the United States ' + 'Department of Defense. In 2024, 26% of revenue was from sales of the ' + 'F-35 fighter. Lockheed Martin is also a contractor for the U.S. ' + 'Department of Energy and the National Aeronautics and Space ' + 'Administration. It also provides products and services to the ' + 'Department of Defense and the Department of Energy to the Department ' + 'of Agriculture and the Environmental Protection Agency.', + 'related': ['RTX:NYSE', 'XOM:NYSE']}, + 'LOW:NYSE': {'about': {'CEO': 'Marvin Ellison', + 'Employees': '222K', + 'Founded': '1921', + 'Headquarters': 'Mooresville, North Carolina, United States', + 'Sector': 'Home improvement'}, + 'logo_file': 'logos/LOW.svg', + 'name': "Lowe's Companies Inc", + 'profile': "Lowe's Companies, Inc. is an American retail company specializing in " + 'home improvement. Headquartered in Mooresville, North Carolina, the ' + 'company operates a chain of retail stores in the United States. As ' + "of October 28, 2022, Lowe's and its related businesses operated " + "2,181 home improvement and hardware stores in North America. Lowe's " + 'is the second-largest hardware chain in the United States behind ' + 'rival the Home Depot and ahead of Menards. It is also the ' + 'second-largest hardware chain in the world, also behind the Home ' + 'Depot, but ahead of European retailers Leroy Merlin, B&Q, and OBI. ' + 'The company previously operated in Australia through the Masters ' + 'Home Improvement joint venture until 2016, in Mexico until 2019, and ' + 'in Canada until selling its operations to Sycamore Partners in 2023.', + 'related': ['HD:NYSE', 'AMZN:NASDAQ', 'JNJ:NYSE']}, + 'LRCX:NASDAQ': {'about': {'CEO': 'Timothy Archer', + 'Employees': '20.6K', + 'Founded': '1980', + 'Sector': 'Semiconductor', + 'Website': 'lamresearch.com'}, + 'logo_file': 'logos/LRCX.svg', + 'name': 'Lam Research Corp', + 'profile': 'Lam Research Corporation is an American supplier of ' + 'wafer-fabrication equipment and related services to the ' + 'semiconductor industry. Its products are used primarily in ' + 'front-end wafer processing, which involves the steps that create ' + 'the active components of semiconductor devices and their wiring. ' + 'The company also builds equipment for back-end wafer-level ' + 'packaging and for related manufacturing markets such as for ' + 'microelectromechanical systems. Lam Research was founded in 1980 ' + 'by David K. Lam and is headquartered in Fremont, California. As ' + 'of 2023, it was the third largest manufacturer in the Bay Area, ' + 'after Tesla and Intuitive Surgical.', + 'related': ['MU:NASDAQ', 'AVGO:NASDAQ', 'AAPL:NASDAQ']}, + 'MA:NYSE': {'about': {'Employees': '39.8K', + 'Founded': 'Nov 3, 1966', + 'Headquarters': 'Purchase, New York, United States', + 'Sector': 'Financial services', + 'Website': 'mastercard.com'}, + 'logo_file': 'logos/MA.svg', + 'name': 'Mastercard Inc', + 'profile': 'Mastercard Inc. is an American multinational payment card services ' + 'corporation headquartered in Purchase, New York. It provides payment ' + 'transaction processing and other related-payment services, including ' + 'travel-related payments and bookings. Its principal business globally ' + 'is to process payments between the banks of merchants and the ' + 'card-issuing banks or credit unions of purchasers who use its branded ' + 'debit, credit and prepaid cards to make purchases. Mastercard has ' + 'been publicly traded since 2006. Mastercard was created by an ' + 'alliance of several banks and regional bankcard associations in ' + 'response to the BankAmericard issued by Bank of America, which later ' + 'became Visa and remains its biggest competitor. Both companies have ' + 'faced numerous antitrust lawsuits. Prior to its initial public ' + 'offering, Mastercard Worldwide was a cooperative owned by the more ' + 'than 25,000 financial institutions that issue its branded cards.', + 'related': ['V:NYSE', 'PM:NYSE', 'KO:NYSE']}, + 'MCD:NYSE': {'about': {'CEO': 'Chris Kempczinski', + 'Employees': '150K', + 'Founded': 'Apr 15, 1955', + 'Headquarters': 'Chicago, Illinois, United States', + 'Sector': 'Restaurant'}, + 'logo_file': 'logos/MCD.svg', + 'name': "McDonald's Corp", + 'profile': "McDonald's Corporation is an American multinational fast food " + 'restaurant chain. As of 2024, it is the second-largest by number of ' + 'locations in the world, behind the Chinese chain Mixue Ice Cream & ' + "Tea. Brothers Richard and Maurice McDonald founded McDonald's in San " + 'Bernardino, California, in 1940 as a hamburger stand. They soon ' + 'franchised the company. The Golden Arches logo was introduced in ' + "1953. In 1955, the businessman Ray Kroc joined McDonald's as a " + 'franchise agent; he bought the company in 1961. In the years since, ' + "it has expanded internationally. Today, McDonald's has more than " + '40,000 restaurant locations worldwide, with around one-quarter in ' + "the US. Other than food sales, McDonald's generates income through " + 'its ownership of 70% of restaurant buildings and 45% of the ' + "underlying land. In 2018, McDonald's was the world's second-largest " + 'private employer after Walmart; most of its 1.7 million employees ' + "work in the restaurant's franchises. McDonald's bestselling licensed " + 'items are their french fries and Big Mac hamburgers; other fare ' + 'includes cheeseburgers, chicken, fish, fruit, and salads.', + 'related': ['HD:NYSE', 'KO:NYSE', 'WMT:NASDAQ', 'JNJ:NYSE']}, + 'MDLZ:NASDAQ': {'about': {'CEO': 'Dirk Van de Put', + 'Employees': '91K', + 'Founded': 'Oct 1, 2012', + 'Sector': 'Food', + 'Website': 'mondelezinternational.com'}, + 'logo_file': 'logos/MDLZ.svg', + 'name': 'MONDELEZ INTERNATIONAL INC Common Stock', + 'profile': 'Mondelez International, Inc. is an American multinational ' + 'confectionery, food, holding, beverage, and snack food company ' + 'based in Chicago. Mondelez has an annual revenue of about $38.5 ' + 'billion and operates in approximately 160 countries. It ranked ' + 'No. 108 in the 2021 Fortune 500 list of the largest United States ' + 'corporations by total revenue. The company has its origins as ' + 'Kraft Foods Inc., which was founded in Chicago in 1923. The ' + 'present enterprise has operated since 2012 when Kraft Foods Inc. ' + 'was renamed Mondelez and retained its snack food business, while ' + 'its North American grocery business was spun off to a new company ' + 'called Kraft Foods Group, which three years later merged with ' + 'Heinz to form Kraft Heinz. The name Mondelez is derived from the ' + 'Latin word mundus and delez, a derivative of the word ' + '"delicious". Mondelez manufactures chocolate, cookies, biscuits, ' + 'gum, confectionery, and powdered beverages.', + 'related': ['PEP:NASDAQ', 'WMT:NASDAQ']}, + 'META:NASDAQ': {'about': {'CEO': 'Mark Zuckerberg', + 'Employees': '78K', + 'Founded': 'Feb 4, 2004', + 'Sector': 'Interactive media', + 'Website': 'meta.com'}, + 'logo_file': 'logos/META.svg', + 'name': 'Meta Platforms Inc', + 'profile': 'Meta Platforms, Inc. is an American multinational technology ' + 'company headquartered in Menlo Park, California. Meta owns and ' + 'operates several prominent social media platforms and ' + 'communication services, including Facebook, Instagram, WhatsApp, ' + 'Messenger, and Threads. The company also operates an advertising ' + 'network for its own sites and third parties; as of 2023, ' + 'advertising accounted for 97.8 percent of its total revenue. Meta ' + 'has been described as a part of Big Tech, which refers to the ' + 'largest six tech companies in the United States, Alphabet, ' + 'Amazon, Apple, Meta, Microsoft, and Nvidia, which are also the ' + 'largest companies in the world by market capitalization. The ' + 'company was originally established in 2004 as TheFacebook, Inc., ' + 'and was renamed Facebook, Inc. in 2005. In 2021, it rebranded as ' + 'Meta Platforms, Inc. to reflect a strategic shift toward ' + 'developing the metaverse—an interconnected digital ecosystem ' + 'spanning virtual and augmented reality technologies. In 2023, ' + 'Meta was ranked 31st on the Forbes Global 2000 list of the ' + "world's largest public companies.", + 'related': ['MSFT:NASDAQ', 'AMZN:NASDAQ', 'NVDA:NASDAQ', 'GOOGL:NASDAQ']}, + 'MMM:NYSE': {'about': {'CEO': 'William M. Brown', + 'Employees': '60.5K', + 'Founded': 'Jun 13, 1902', + 'Headquarters': 'Saint Paul, Minnesota, United States', + 'Sector': 'Conglomerate', + 'Website': '3m.com'}, + 'logo_file': 'logos/MMM.svg', + 'name': '3M Co', + 'profile': 'The 3M Company is an American multinational conglomerate operating ' + 'in the fields of industry, worker safety, and consumer goods. Based ' + 'in the Saint Paul suburb of Maplewood, the company produces over ' + '60,000 products, including adhesives, abrasives, laminates, passive ' + 'fire protection, personal protective equipment, window films, paint ' + 'protection film, electrical, electronic connecting, insulating ' + 'materials, car-care products, electronic circuits, and optical ' + 'films. Among its best-known consumer brands are Scotch Tape, ' + 'Scotchgard surface protectants, Post-it notes, and Nexcare adhesive ' + "bandages. 3M's stock ticker symbol is MMM and is listed on the New " + 'York Stock Exchange, Inc., and the SIX Swiss Exchange. 3M made $35.4 ' + 'billion in total sales in 2021 and ranked number 102 in the Fortune ' + '500 list of the largest United States corporations by total revenue. ' + 'As of 2021, the company had approximately 95,000 employees and ' + 'operations in more than 70 countries. There are a few international ' + 'subsidiaries, such as 3M India, 3M Japan, and 3M Canada.', + 'related': ['AXP:NYSE', 'DOW:NYSE', 'MCD:NYSE', 'PM:NYSE']}, + 'MPC:NYSE': {'about': {'CEO': 'Maryann Mannen', + 'Employees': '18.5K', + 'Founded': 'Nov 9, 2009', + 'Headquarters': 'Findlay, Ohio, United States', + 'Sector': 'Energy', + 'Website': 'marathonpetroleum.com'}, + 'logo_file': 'logos/MPC.svg', + 'name': 'Marathon Petroleum Corp', + 'profile': 'Marathon Petroleum Corporation is an American petroleum refining, ' + 'marketing, and transportation company headquartered in Findlay, ' + 'Ohio. The company was a wholly owned subsidiary of Marathon Oil ' + 'until a corporate spin-off in 2011. Marathon Petroleum traces its ' + 'origin from a number of small oil companies in Ohio that banded ' + 'together in 1887. These formed The Ohio Oil Company established in ' + 'Lima, Ohio. It became the largest oil producer in the state. By ' + '1889, the company was acquired by the Standard Oil Trust and six ' + 'years later its headquarters was moved to Findlay. In 1906, the ' + 'company built its first oil pipeline, which connected its facilities ' + 'in Martinsville, Illinois, and Preble, Indiana. After the United ' + 'States Supreme Court ordered its parent company to break up as a ' + 'result of the Sherman Anti-Trust Act in 1911, Ohio Oil again became ' + 'independent. It expanded its operations by purchasing oil fields ' + 'outside of Ohio. The company also started oil refining. In 1924, the ' + 'company discovered oil in Texas. In the same year, it acquired ' + 'Lincoln Oil Refining Company. This purchase included a refinery and ' + '17 brand service stations in Indiana.', + 'related': ['XOM:NYSE', 'PSX:NYSE']}, + 'MRK:NYSE': {'about': {'CEO': 'Robert M Davis', + 'Employees': '74K', + 'Founded': 'Jan 1, 1891', + 'Sector': 'Pharmaceutical industry', + 'Website': 'merck.com'}, + 'logo_file': 'logos/MRK.svg', + 'name': 'Merck & Co Inc', + 'profile': 'Merck & Co., Inc. is an American multinational pharmaceutical ' + 'company headquartered in Rahway, New Jersey. The company does ' + 'business as Merck Sharp & Dohme or MSD outside the United States and ' + 'Canada. The company is ranked fifth on the list of largest ' + "biomedical companies by revenue. The company's revenues are " + 'primarily from cancer treatments, vaccines, and animal health ' + "products. In 2024, 46% of the company's revenue, or $29.5 billion, " + 'came from sales of Keytruda, a PD-1 inhibitor used to treat various ' + "types of cancers, and 13% of the company's revenue, or $8.6 billion, " + 'came from sales of Gardasil, an HPV vaccine. In addition, 9% of the ' + "company's revenue, or $5.8 billion, came from the sales of animal " + 'health products. The company is ranked 65th on the Fortune 500 and ' + '76th on the Forbes Global 2000. In 1891, Merck & Co. was established ' + 'as the American affiliate of the Merck Group, founded by the Merck ' + 'family, and the companies are still in trademark disputes in several ' + 'countries over the right to use the name "Merck".', + 'related': ['PFE:NYSE', 'LLY:NYSE', 'AAPL:NASDAQ', 'VZ:NYSE', 'JNJ:NYSE']}, + 'MS:NYSE': {'about': {'CEO': 'Ted Pick', + 'Employees': '84K', + 'Founded': 'Sep 5, 1935', + 'Headquarters': 'New York, New York, United States', + 'Sector': 'Capital market', + 'Website': 'morganstanley.com'}, + 'logo_file': 'logos/MS.svg', + 'name': 'Morgan Stanley', + 'profile': 'Morgan Stanley is an American multinational investment bank and ' + 'financial services company headquartered at 1585 Broadway in Midtown ' + 'Manhattan, New York City. With offices in 42 countries and more than ' + "80,000 employees, the firm's clients include corporations, " + 'governments, institutions, and individuals. Morgan Stanley ranked No. ' + '61 in the 2023 Fortune 500 list of the largest United States ' + 'corporations by total revenue and in the same year ranked No. 30 in ' + 'the Forbes Global 2000. The original Morgan Stanley came into ' + 'existence on September 16, 1935, in response to the Glass–Steagall ' + 'Act, which required the splitting of American commercial and ' + 'investment banking businesses. Original founders were J.P. Morgan & ' + 'Co. partners Henry Sturgis Morgan, Harold Stanley, and others. In its ' + 'first year, the company operated with a 24% market share in public ' + 'offerings and private placements. The current Morgan Stanley is the ' + 'result of the 1997 merger of the original Morgan Stanley with Dean ' + "Witter Discover & Co.. Dean Witter's chairman and CEO, Philip J. " + 'Purcell, became the chairman and CEO of the newly merged Morgan ' + 'Stanley Dean Witter Discover & Co.', + 'related': ['JPM:NYSE', 'GS:NYSE', 'WFC:NYSE', 'BAC:NYSE']}, + 'MSFT:NASDAQ': {'about': {'CEO': 'Satya Nadella', + 'Employees': '228K', + 'Founded': 'Apr 4, 1975', + 'Headquarters': 'Redmond, Washington, United States', + 'Sector': 'Software', + 'Website': 'microsoft.com'}, + 'logo_file': 'logos/MSFT.svg', + 'name': 'Microsoft Corp', + 'profile': 'Microsoft Corporation is an American multinational technology ' + 'company headquartered in Redmond, Washington. The company became ' + 'influential in the rise of personal computers through software ' + 'like Windows and has since expanded into areas such as Internet ' + 'services, cloud computing, artificial intelligence, video gaming, ' + 'and more. A Big Tech company, Microsoft is the largest software ' + 'company by revenue, one of the most valuable public companies, ' + 'and one of the most valuable brands globally. Founded in 1975 by ' + 'Bill Gates and Paul Allen to market BASIC interpreters for the ' + 'Altair 8800, Microsoft rose to dominate the PC operating system ' + 'market with MS-DOS in the mid-1980s, followed by Windows. The ' + "company's 1986 initial public offering and subsequent rise in its " + 'share price created three billionaires and an estimated 12,000 ' + 'millionaires among Microsoft employees. Since the 1990s, it has ' + 'increasingly diversified its business. Steve Ballmer replaced ' + 'Gates as CEO in 2000.', + 'related': ['AAPL:NASDAQ', + 'AMZN:NASDAQ', + 'TSLA:NASDAQ', + 'NFLX:NASDAQ', + 'NVDA:NASDAQ']}, + 'MU:NASDAQ': {'about': {'CEO': 'Sanjay Mehrotra', + 'Employees': '53K', + 'Founded': 'Oct 5, 1978', + 'Headquarters': 'Boise, Idaho, United States', + 'Sector': 'Semiconductor', + 'Website': 'micron.com'}, + 'logo_file': 'logos/MU.svg', + 'name': 'Micron Technology Inc', + 'profile': 'Micron Technology, Inc. is an American multinational semiconductor ' + 'company that manufactures computer memory and computer data storage ' + 'products, including dynamic random-access memory, flash memory, ' + 'High Bandwidth Memory, and solid-state drives. Founded in 1978 in ' + 'Boise, Idaho, Micron is the only major American computer memory ' + 'manufacturer. It is one of the "Big Three" computer memory ' + 'manufacturers, along with the South Korean companies Samsung ' + 'Electronics and SK Hynix. Micron marketed its consumer products ' + 'under the brand Crucial, with the sub-brand Ballistix being used to ' + 'denote products targeting gaming computers, until its ' + 'disestablishment on 2026. Micron and Intel together created IM ' + 'Flash Technologies, which produced NAND flash memory. It owned ' + 'Lexar between 2006 and 2017. Sanjay Mehrotra has served as ' + 'president and CEO of Micron since 2017. On May 26, 2026, Micron ' + 'became the latest U.S. company to reach a US$1 trillion market ' + 'capitalization, amid surging demand for its HBM chips.', + 'related': ['MSFT:NASDAQ', 'TSLA:NASDAQ', 'AVGO:NASDAQ']}, + 'NEE:NYSE': {'about': {'CEO': 'John W. Ketchum', + 'Employees': '17.4K', + 'Founded': 'Dec 28, 1925', + 'Headquarters': 'Juno Beach, Florida, United States', + 'Sector': 'Public utility', + 'Website': 'nexteraenergy.com'}, + 'logo_file': 'logos/NEE.svg', + 'name': 'NextEra Energy Inc', + 'profile': 'NextEra Energy, Inc. is an American energy company that is the ' + "world's largest electric utility holding company by market " + 'capitalization, with a valuation of over $190 billion as of March ' + '2026. NextEra Energy had revenues of $24.8 billion and 16,700 ' + 'employees throughout the US and Canada in 2024. It has a current ' + 'generating capacity of 73 gigawatts. Its subsidiaries include ' + 'Florida Power & Light, NextEra Energy Resources, XPLR Energy ' + 'Partners, and NextEra Energy Services. FPL, the largest of its ' + 'subsidiaries, is the third largest electric utility company in the ' + 'United States. NEER, together with its affiliated entities, is the ' + "world's largest generator of renewable energy from wind and solar. " + 'In addition to wind and solar, NextEra Energy Resources owns and ' + 'operates generating plants powered by natural gas, nuclear energy, ' + "and oil. As of March 2025, approximately 36% of NextEra Energy's " + 'generating capacity was from fossil fuels and non-renewables, down ' + 'from 41% in 2020.', + 'related': ['D:NYSE', 'NVDA:NASDAQ']}, + 'NEM:NYSE': {'about': {'CEO': 'Natascha Viljoen', + 'Employees': '17.5K', + 'Founded': '1921', + 'Headquarters': 'Denver, Colorado, United States', + 'Sector': 'Gold', + 'Website': 'newmont.com'}, + 'logo_file': 'logos/NEM.svg', + 'name': 'Newmont Corporation', + 'profile': 'Newmont Corporation is an American gold mining company based in ' + "Denver, Colorado. It is the world's largest gold mining corporation. " + 'Incorporated in 1921, it holds ownership of gold mines in the United ' + 'States, Canada, Mexico, the Dominican Republic, Australia, Ghana, ' + 'Argentina, Peru, and Suriname. Newmont is also a partial owner of ' + 'Nevada Gold Mines in Nevada with Barrick Mining being the majority ' + 'owner and operator. In addition to gold, Newmont mines copper, ' + 'silver, zinc and lead. The Newmont Corporation bought Canadian ' + 'mining company Goldcorp in 2019 for USD $10 billion. In 2023, ' + 'Newmont acquired Newcrest Mining for USD $16.8 billion. Newmont is ' + 'the only gold mining company listed in the S&P 500, and has ' + 'approximately 31,600 employees globally.', + 'related': []}, + 'NFLX:NASDAQ': {'about': {'CEO': 'Ted Sarandos | Greg Peters', + 'Employees': '16K', + 'Founded': 'Aug 29, 1997', + 'Headquarters': 'Los Gatos, California, United States', + 'Sector': 'Entertainment', + 'Website': 'netflix.com'}, + 'logo_file': 'logos/NFLX.svg', + 'name': 'Netflix Inc', + 'profile': 'Netflix, Inc. is an American media company founded on August 29, ' + '1997, by Reed Hastings and Marc Randolph in Scotts Valley, ' + 'California, and currently based in Los Gatos, California, with ' + 'production offices and stages at the Los Angeles-based Hollywood ' + 'studios and the Albuquerque Studios. It owns and operates an ' + 'eponymous over-the-top subscription video on-demand service, ' + 'which showcases acquired and original programming as well as ' + 'third-party content licensed from other production companies and ' + 'distributors. Netflix is also the first streaming media company ' + 'to be a member of the Motion Picture Association. Netflix ' + 'initially both sold and rented DVDs by mail, but the sales were ' + 'eliminated within a year to focus on the DVD rental business. In ' + '2007, Netflix introduced streaming media and video on demand. The ' + 'company expanded to Canada in 2010, followed by Latin America and ' + 'the Caribbean. In 2011, the service began to acquire and produce ' + 'original content, beginning with the crime drama Lilyhammer. The ' + 'company is ranked 117th on the Fortune 500 and 219th on the ' + 'Forbes Global 2000.', + 'related': ['MSFT:NASDAQ', 'AAPL:NASDAQ', 'AMZN:NASDAQ', 'TSLA:NASDAQ']}, + 'NKE:NYSE': {'about': {'Employees': '73K', + 'Founded': 'Jan 25, 1964', + 'Headquarters': 'Beaverton, Oregon, United States', + 'Sector': 'Footwear'}, + 'logo_file': 'logos/NKE.svg', + 'name': 'Nike Inc', + 'profile': 'Nike, Inc. is an American athletic footwear and apparel corporation ' + "headquartered near Beaverton, Oregon. It is the world's largest " + 'supplier of athletic shoes and apparel and a major manufacturer of ' + 'sports equipment, with revenue in excess of US$46 billion in its ' + 'fiscal year 2022. The company was founded on January 25, 1964, as ' + '"Blue Ribbon Sports", by Bill Bowerman and Phil Knight, and ' + 'officially became Nike, Inc. on May 30, 1971. The company takes its ' + 'name from Nike, the Greek goddess of victory. Nike markets its ' + 'products under its own brand, as well as Nike Golf, Nike Pro, Nike+, ' + 'Nike Blazers, Air Force 1, Nike Dunk, Air Max, Foamposite, Nike ' + 'Skateboarding and Nike CR7. The company also sells products under ' + 'its Air Jordan brand and its Converse subsidiary. Nike also owned ' + 'Bauer Hockey from 1995 to 2008, and previously owned Cole Haan, ' + 'Umbro, and Hurley International. In addition to manufacturing ' + 'sportswear and equipment, the company operates retail stores under ' + 'the Niketown name. Nike sponsors many high-profile athletes and ' + 'sports teams around the world, with the highly recognized trademarks ' + 'of "Just Do It" and the Swoosh logo. As of 2024, it employed 83,700 ' + 'people worldwide.', + 'related': ['AMZN:NASDAQ', 'SBUX:NASDAQ', 'UNH:NYSE']}, + 'NOW:NYSE': {'about': {'CEO': 'Bill McDermott', + 'Employees': '29.2K', + 'Founded': '2004', + 'Headquarters': 'Santa Clara, California, United States', + 'Sector': 'Software', + 'Website': 'servicenow.com'}, + 'logo_file': 'logos/NOW.svg', + 'name': 'ServiceNow Inc', + 'profile': 'ServiceNow, Inc. is an American software company that supplies cloud ' + 'computing platforms for the creation and management of automated ' + 'business workflows. The company was founded in Santa Clara, ' + 'California, United States, in 2003 by Fred Luddy. It is listed on ' + 'the New York Stock Exchange and is a constituent of the S&P 100 and ' + 'S&P 500 indices.', + 'related': ['CRM:NYSE', + 'QQQ:NASDAQ', + 'TSLA:NASDAQ', + 'MSFT:NASDAQ', + 'ADBE:NASDAQ']}, + 'NVDA:NASDAQ': {'about': {'CEO': 'Jensen Huang', + 'Employees': '42K', + 'Founded': 'Apr 5, 1993', + 'Headquarters': 'Santa Clara, California, United States', + 'Sector': 'Semiconductor'}, + 'logo_file': 'logos/NVDA.svg', + 'name': 'NVIDIA Corp', + 'profile': 'Nvidia Corporation is an American multinational technology ' + 'company headquartered in Santa Clara, California. The company ' + 'develops graphics processing units, systems on chips, and ' + 'application programming interfaces for data science, ' + 'high-performance computing, artificial intelligence, and mobile ' + 'and automotive applications. Founded in 1993 by Jensen Huang, ' + 'Chris Malachowsky, and Curtis Priem, Nvidia has been widely ' + 'described as a Big Tech company. Originally focused on GPUs for ' + 'video games, Nvidia broadened their use into other markets, ' + 'including artificial intelligence, professional visualization, ' + "and supercomputing. The company's product lines include GeForce " + 'GPUs for gaming and creative workloads, and professional GPUs for ' + 'edge computing, scientific research, and industrial applications. ' + 'As of the first quarter of 2025, Nvidia held a 92% share of the ' + 'discrete desktop and laptop GPU market. In the early-to-mid ' + '2000s, the company invested over a billion dollars to develop ' + 'CUDA, a software platform and API that enabled GPUs to run ' + 'massively parallel programs for a broad range of ' + 'compute-intensive applications.', + 'related': ['TSLA:NASDAQ', + 'MSFT:NASDAQ', + 'AAPL:NASDAQ', + 'MU:NASDAQ', + 'AVGO:NASDAQ']}, + 'O:NYSE': {'about': {'CEO': 'Sumit Roy', + 'Employees': '544', + 'Founded': 'Feb 18, 1969', + 'Headquarters': 'San Diego, California, United States', + 'Sector': 'Real estate investment trust', + 'Website': 'realtyincome.com'}, + 'logo_file': 'logos/O.svg', + 'name': 'Realty Income Corp', + 'profile': 'Realty Income Corporation is a real estate investment trust that ' + 'invests in free-standing, single-tenant commercial properties in the ' + 'United States, the United Kingdom, and six other countries in Europe. ' + 'These properties are subject to NNN Leases. The company is organized ' + 'in Maryland with its headquarters in San Diego, California. The ' + 'company is one of a few real estate investment trusts that pays ' + 'dividends monthly, rather than quarterly, and has registered a ' + 'trademark for the phrase "The Monthly Dividend Company".', + 'related': ['VZ:NYSE', 'NVDA:NASDAQ']}, + 'ORCL:NYSE': {'about': {'CEO': 'Clayton M. Magouyrk Discretionary sell 02/11/2026 10K $1.55M', + 'Employees': '141K', + 'Founded': 'Jun 16, 1977', + 'Sector': 'Software', + 'Website': 'oracle.com'}, + 'logo_file': 'logos/ORCL.svg', + 'name': 'Oracle Corp', + 'profile': 'Oracle Corporation is an American multinational technology company ' + 'headquartered in Austin, Texas. Co-founded in Santa Clara, ' + 'California, in 1977 by Bob Miner, Ed Oates, and current chairman of ' + 'the board and chief technology officer Larry Ellison, Oracle is ' + 'among the 20 largest companies in the world by market cap, and ' + 'ranked 66th on the Forbes Global 2000 as of 2025. The company sells ' + 'database software, enterprise applications, and cloud ' + "infrastructure and hardware. Oracle's core application software is " + 'a suite of enterprise software products, including enterprise ' + 'resource planning, human capital management, customer experience ' + 'and supply chain management software.', + 'related': ['MSFT:NASDAQ', + 'NVDA:NASDAQ', + 'AMZN:NASDAQ', + 'AAPL:NASDAQ', + 'TSLA:NASDAQ']}, + 'OXY:NYSE': {'about': {'CEO': 'Richard Jackson', + 'Employees': '10.4K', + 'Founded': '1920', + 'Headquarters': 'Houston, Texas, United States', + 'Sector': 'Energy', + 'Website': 'oxy.com'}, + 'logo_file': 'logos/OXY.svg', + 'name': 'Occidental Petroleum Corp', + 'profile': 'Occidental Petroleum Corporation is an American company engaged in ' + 'hydrocarbon exploration in the United States and the Middle East as ' + 'well as petrochemical manufacturing in the United States, Canada, ' + 'and Chile. It is incorporated under the Delaware General Corporation ' + 'Law and headquartered in Houston. The company ranked 183rd on the ' + '2021 Fortune 500 based on its 2020 revenues and 670th on the 2021 ' + 'Forbes Global 2000.', + 'related': ['CVX:NYSE', 'XOM:NYSE', 'AXP:NYSE', 'FCX:NYSE']}, + 'PANW:NASDAQ': {'about': {'CEO': 'Nikesh Arora', + 'Employees': '21.5K', + 'Founded': 'Mar 1, 2005', + 'Headquarters': 'Santa Clara, California, United States', + 'Sector': 'Software', + 'Website': 'paloaltonetworks.com'}, + 'logo_file': 'logos/PANW.svg', + 'name': 'Palo Alto Networks Inc', + 'profile': 'Palo Alto Networks, Inc. is an American multinational ' + 'cybersecurity company with headquarters in Santa Clara, ' + 'California. The core product is a platform that includes advanced ' + 'firewalls and cloud-based offerings that extend those firewalls ' + 'to cover other aspects of security. The company serves over ' + '70,000 organizations in over 150 countries, including 85 of the ' + 'Fortune 100. It is home to the Unit 42 threat research team and ' + 'hosts the Ignite cybersecurity conference. It is a partner ' + 'organization of the World Economic Forum. In June 2018, former ' + 'Google and SoftBank executive Nikesh Arora joined the company as ' + 'Chairman and CEO.', + 'related': ['AAPL:NASDAQ', 'AMZN:NASDAQ', 'NFLX:NASDAQ']}, + 'PEP:NASDAQ': {'about': {'CEO': 'Ramon Laguarta', + 'Employees': '306K', + 'Founded': '1965', + 'Headquarters': 'Purchase, New York, United States', + 'Sector': 'Drink', + 'Website': 'pepsico.com'}, + 'logo_file': 'logos/PEP.svg', + 'name': 'PepsiCo Inc', + 'profile': 'PepsiCo, Inc. is an American multinational food and beverage ' + 'corporation headquartered in Harrison, New York, in the hamlet of ' + "Purchase. PepsiCo's business encompasses all aspects of the food " + 'and beverage market. It oversees the manufacturing, distribution, ' + 'and marketing of its products. PepsiCo was formed in 1965 with the ' + 'merger of the Pepsi-Cola Company and Frito-Lay, Inc., PepsiCo has ' + 'since expanded from its namesake product Pepsi to a diversified ' + 'range of food and beverage brands. Significant acquisitions ' + 'include Tropicana Products in 1998, the Quaker Oats Company in ' + '2001, which added the Gatorade brand to the Pepsi portfolio, and ' + 'Pioneer Foods in 2020 for US$1.7 billion. As of January 2021, the ' + 'company possesses 23 brands that each have over $1 billion in ' + 'sales annually. PepsiCo products were distributed across more than ' + '200 countries and territories, resulting in annual net revenues of ' + 'over US$70 billion. PepsiCo is the second-largest food and ' + 'beverage business in the world based on net revenue, profit, and ' + "market capitalization, behind Nestlé. In 2023, the company's seat " + 'in the Forbes Global 2000 was 82.', + 'related': ['KO:NYSE', 'PG:NYSE', 'PANW:NASDAQ', 'WMT:NASDAQ', 'JNJ:NYSE']}, + 'PFE:NYSE': {'about': {'CEO': 'Albert Bourla', + 'Employees': '75K', + 'Founded': '1849', + 'Headquarters': 'New York, New York, United States', + 'Sector': 'Pharmaceutical industry', + 'Website': 'pfizer.com'}, + 'logo_file': 'logos/PFE.svg', + 'name': 'Pfizer Inc', + 'profile': 'Pfizer Inc. is an American multinational pharmaceutical and ' + 'biotechnology corporation headquartered at The Spiral in Manhattan, ' + 'New York City. Founded in 1849 in New York by German entrepreneurs ' + 'Charles Pfizer and Charles F. Erhart, Pfizer is one of the oldest ' + 'pharmaceutical companies in North America. Pfizer develops and ' + 'produces medication and vaccines for immunology, oncology, ' + "cardiology, endocrinology, and neurology. The company's largest " + 'products by sales are Eliquis, Prevnar, Paxlovid, Vyndaqel, ' + "Comirnaty, and Ibrance. In 2025, 59% of the company's revenues came " + 'from the United States, 5% came from China, and 36% came from other ' + 'countries. The company is ranked fifth on the list of largest ' + 'biomedical companies by revenue. It is ranked the 69th on the ' + 'Fortune 500 and 73rd on the Forbes Global 2000.', + 'related': ['VZ:NYSE', 'MRK:NYSE', 'AAPL:NASDAQ', 'TSLA:NASDAQ', 'NVDA:NASDAQ']}, + 'PG:NYSE': {'about': {'CEO': 'Shailesh G. Jejurikar | Fama Francisco', + 'Employees': '109K', + 'Founded': 'Oct 31, 1837', + 'Sector': 'Household goods', + 'Website': 'us.pg.com'}, + 'logo_file': 'logos/PG.svg', + 'name': 'Procter & Gamble Co', + 'profile': 'The Procter & Gamble Company is an American multinational consumer ' + 'goods corporation incorporated and headquartered in Cincinnati, Ohio. ' + 'The company was founded in 1837 by William Procter and James Gamble. ' + 'The company operates five divisions: Beauty, which includes Head & ' + 'Shoulders, Herbal Essences, Pantene, Rejoice, Olay, Old Spice, ' + 'Safeguard, Secret, SK-II, and Native; Grooming, which includes Braun, ' + 'Gillette, and Venus; Health Care, which includes Crest, Oral-B, ' + 'Metamucil, Neurobion, Pepto-Bismol, and Vicks; Fabric & Home Care, ' + 'which includes Ariel, Downy, Gain, Tide, Cascade, Dawn, Fairy, ' + 'Febreze, Mr. Clean, and Swiffer; Baby, Feminine & Family Care, which ' + 'includes Luvs, Pampers, Always, Tampax, Bounty, Charmin, and Puffs. ' + 'The company owns brands that are in many cases the global brand ' + 'leader in their category. Many of the brands have a market share ' + 'greater than 25%. The company generates 48% of its sales in the ' + 'United States and 52% of its sales in other countries. The company ' + 'manufactures 90% of its domestically sold merchandise in the United ' + 'States. The company is ranked 51st on the Fortune 500 and 60th on the ' + 'Forbes Global 2000.', + 'related': ['JNJ:NYSE', 'KO:NYSE', 'PM:NYSE', 'MRK:NYSE', 'JPM:NYSE']}, + 'PLD:NYSE': {'about': {'CEO': 'Dan Letter', + 'Employees': '2.8K', + 'Founded': '1983', + 'Headquarters': 'San Francisco, California, United States', + 'Sector': 'Real estate investment trust', + 'Website': 'prologis.com'}, + 'logo_file': 'logos/PLD.svg', + 'name': 'Prologis Inc', + 'profile': 'Prologis, Inc. is a real estate investment trust headquartered in ' + 'San Francisco, California that invests in logistics facilities. The ' + 'company was formed through the merger of AMB Property Corporation ' + 'and Prologis in June 2011, which made Prologis the largest ' + 'industrial real estate company in the world. As of 2025, the company ' + 'operates more than 15,000 land acres and over 6,000 buildings ' + 'comprising about 1.3 billion square feet in 20 countries across ' + 'North America, Latin America, Europe, and Asia. According to The ' + 'Economist, its business strategy is focused on warehouses that are ' + 'located close to huge urban areas where land is scarce. It serves ' + 'about 6,600 tenants. Prologis began to expand its non-real estate ' + 'business, Essentials, in 2018, offering customers solar power, ' + 'racking systems, forklifts, generators, EV charging infrastructure, ' + 'and other logistics tech equipment for purchase.', + 'related': ['EQIX:NASDAQ', 'AMT:NYSE', 'SPY:NYSEARCA', 'SPG:NYSE']}, + 'PM:NYSE': {'about': {'CEO': 'Jacek Olczak', + 'Employees': '84.9K', + 'Founded': '1847', + 'Headquarters': 'Stamford, Connecticut, United States', + 'Sector': 'Tobacco', + 'Website': 'pmi.com'}, + 'logo_file': 'logos/PM.svg', + 'name': 'Philip Morris International Inc.', + 'profile': 'Philip Morris International Inc. is a tobacco company, with products ' + 'sold in over 180 countries. Marlboro is PMI’s most recognized brand, ' + 'but in the last quarter of 2023, Iqos generated the greatest revenue. ' + 'Philip Morris International is often referred to as one of the ' + 'companies comprising Big Tobacco. The company ranked No. 121 in the ' + '2025 Fortune 500 list of the largest US corporations by total ' + 'revenue. The company originated in 1847 in London, but Philip Morris ' + 'International itself was established in 1987 as an operating company ' + 'of Philip Morris Companies Inc. In 2003, Philip Morris Companies ' + 'changed its name to Altria Group. In March 2008, PMI was spun off ' + 'from Altria and has been an entirely separate entity since then. ' + "Philip Morris USA, a subsidiary of PMI's former parent, keeps " + 'ownership of the Philip Morris brands for the US market. The ' + "company's legal seat is in Stamford, Connecticut. PMI's operational " + 'headquarters are in Lausanne, Switzerland. With tobacco being ' + 'addictive and a leading cause of preventable death globally, the ' + 'company has faced litigation and regulatory measures from governments ' + 'and has been the subject of scrutiny by the World Health ' + 'Organization.', + 'related': ['MA:NYSE', 'PG:NYSE', 'MMM:NYSE']}, + 'PSA:NYSE': {'about': {'CEO': 'Joseph D. Russell Jr.', + 'Employees': '5.77K', + 'Founded': 'Aug 14, 1972', + 'Headquarters': 'Glendale, California, United States', + 'Sector': 'Real estate investment trust', + 'Website': 'publicstorage.com'}, + 'logo_file': 'logos/PSA.svg', + 'name': 'Public Storage', + 'profile': 'Public Storage, headquartered in Glendale, California, is a real ' + 'estate investment trust that invests in self storage. It is the ' + 'largest brand of self-storage services in the US and owns ' + 'approximately 9% of the self storage square footage in the U.S. As ' + 'of December 31, 2024, the company operated 3,073 self-storage ' + 'facilities containing an aggregate of 221 million net rentable ' + 'square feet of space. The company also provides insurance services ' + 'for tenants and manages 307 self storage facilities for other ' + 'owners. Public Storage Inc. was founded in 1972 by B. Wayne Hughes ' + 'and Kenneth Volk Jr. It grew to 1,000 locations by 1989, using ' + 'funding from investors in real estate limited partnerships. The ' + 'private company was re-structured as a publicly traded REIT in 1995, ' + 'when Storage Equities merged with Public Storage and adopted its ' + 'name. In 2006, it acquired Shurgard Storage Centers, an owner and ' + 'operator of storage facilities in Europe, in a $5.5 billion ' + 'transaction; the company was spun-out and Public Storage retains a ' + '35% interest.', + 'related': ['PLD:NYSE', 'EQIX:NASDAQ', 'SPG:NYSE']}, + 'PSX:NYSE': {'about': {'CEO': 'Mark Lashier', + 'Employees': '12.6K', + 'Founded': '2011', + 'Headquarters': 'Houston, Texas, United States', + 'Sector': 'Energy', + 'Website': 'phillips66.com'}, + 'logo_file': 'logos/PSX.svg', + 'name': 'Phillips 66', + 'profile': 'The Phillips 66 Company is an American multinational energy company ' + 'headquartered in Westchase, Houston, Texas. Its name, dating back to ' + '1927 as a trademark of the Phillips Petroleum Company, assisted in ' + 'establishing the newly reconfigured Phillips 66. The company today ' + 'was formed ten years after Phillips merged with Conoco to form ' + 'ConocoPhillips. The merged company spun off its refining, chemical, ' + 'and retail assets – known in the oil industry as downstream ' + 'operations – into a new company bearing the Phillips 66 name. It ' + 'began trading on the New York Stock Exchange on May 1, 2012, under ' + 'the ticker PSX. The company is engaged in refining, transporting, ' + 'and marketing natural gas liquids petrochemicals. It is also active ' + 'in the research and development of emerging energy sources and ' + 'partners with Chevron on chemicals through a joint venture known as ' + 'Chevron Phillips Chemical. Phillips 66 is ranked No. 29 on the ' + 'Fortune 500 list and No. 74 on the Fortune Global 500 list as of ' + '2022, with revenues of over $115 billion USD.', + 'related': ['DIS:NYSE', 'COP:NYSE', 'MPC:NYSE', 'JNJ:NYSE', 'JPM:NYSE']}, + 'QCOM:NASDAQ': {'about': {'CEO': 'Cristiano Amon', + 'Employees': '52K', + 'Founded': 'Jul 1985', + 'Headquarters': 'San Diego, California, United States', + 'Sector': 'Semiconductor', + 'Website': 'qualcomm.com'}, + 'logo_file': 'logos/QCOM.svg', + 'name': 'Qualcomm Inc', + 'profile': 'Qualcomm Incorporated is an American multinational technology ' + 'corporation headquartered in San Diego, California. The company ' + 'develops and designs technologies for global communications and ' + 'computing, including next-generation mobile networks, smart ' + 'devices, and semiconductors — such as mobile processors and ' + 'artificial intelligence chips. The company holds essential ' + 'patents for global mobile communication standards including 5G, ' + '4G, CDMA2000, TD-SCDMA and WCDMA. Founded in 1985 by Irwin Jacobs ' + "and six other co-founders, Qualcomm's early research into " + 'code-division multiple access wireless technology was financed ' + 'through revenue from its Omnitracs satellite communications ' + 'system. CDMA was later adopted as a 2G standard in North America, ' + 'incorporating Qualcomm patents. This adoption led to licensing ' + 'disputes over pricing and access to essential patents. Over time, ' + 'Qualcomm expanded into semiconductor products, operating largely ' + 'under a fabless manufacturing model. Its Snapdragon line of ' + 'processors is widely used in smartphones and other smart devices ' + 'by multiple manufacturers.', + 'related': ['INTC:NASDAQ', 'MSFT:NASDAQ', 'MU:NASDAQ', 'AVGO:NASDAQ']}, + 'QQQ:NASDAQ': {'about': {}, + 'logo_file': 'logos/QQQ.svg', + 'name': 'Invesco QQQ Trust, Series 1', + 'profile': '', + 'related': ['SPY:NYSEARCA', 'TSLA:NASDAQ']}, + 'RTX:NYSE': {'about': {'CEO': 'Christopher T. Calio | Christopher T. Calio', + 'Employees': '180K', + 'Founded': 'Apr 3, 2020', + 'Sector': 'Aerospace and Defense', + 'Website': 'rtx.com'}, + 'logo_file': 'logos/RTX.svg', + 'name': 'Rtx Corp', + 'profile': 'RTX Corporation, formerly Raytheon Technologies Corporation, is an ' + 'American multinational aerospace and defense conglomerate ' + 'headquartered in Arlington, Virginia. It is one of the largest ' + 'aerospace and defense manufacturers in the world by revenue and ' + 'market capitalization, as well as one of the largest providers of ' + "intelligence services. In 2023, the company's rank in the Forbes " + 'Global 2000 was 79. RTX manufactures aircraft engines, avionics, ' + 'aerostructures, cybersecurity solutions, guided missiles, air ' + 'defense systems, satellites, and drones. The company is a large ' + 'military contractor, getting much of its revenue from the U.S. ' + 'government. The company was formed in 2020 by a merger of equals ' + 'between the aerospace subsidiaries of United Technologies ' + 'Corporation and the Raytheon Company. Before the merger, UTC spun ' + 'off its Otis Elevator Company and Carrier Corporation subsidiaries. ' + 'The merged company adopted the better-known name of Raytheon in the ' + 'form Raytheon Technologies Corporation and transferred its ' + 'headquarters to Waltham, Massachusetts. Former UTC CEO and chairman ' + 'Gregory J.', + 'related': ['LMT:NYSE', 'BA:NYSE', 'TSLA:NASDAQ']}, + 'SBUX:NASDAQ': {'about': {'CEO': 'Brian Niccol', + 'Employees': '381K', + 'Founded': 'Mar 30, 1971', + 'Headquarters': 'Seattle, Washington, United States', + 'Sector': 'Restaurant', + 'Website': 'starbucks.com'}, + 'logo_file': 'logos/SBUX.svg', + 'name': 'Starbucks Corp', + 'profile': 'Starbucks Corporation is an American multinational chain of ' + 'coffeehouses and roastery reserves headquartered in Seattle, ' + 'Washington. It was founded in 1971 by Jerry Baldwin, Zev Siegl, ' + "and Gordon Bowker at Seattle's Pike Place Market initially as a " + 'coffee bean wholesaler. Starbucks was converted into a coffee ' + 'shop serving espresso-based drinks under the ownership of Howard ' + 'Schultz, who was chief executive officer from 1986 to 2000 and ' + 'led the aggressive expansion of the franchise across the West ' + 'Coast of the United States. As of November 2022, the company had ' + '35,711 stores in 80 countries, 15,873 of which were located in ' + "the United States. Of Starbucks' U.S.-based stores, over 8,900 " + 'are company-operated, while the remainder are licensed. It is the ' + "world's largest coffeehouse chain. The company is ranked 120th on " + 'the Fortune 500 and 303rd on the Forbes Global 2000, as of 2022. ' + 'The rise of the second wave of coffee culture is generally ' + 'attributed to Starbucks, which introduced a wider variety of ' + 'coffee experiences.', + 'related': ['MSFT:NASDAQ', + 'NKE:NYSE', + 'AMZN:NASDAQ', + 'ACN:NYSE', + 'TSLA:NASDAQ']}, + 'SCHD:NYSEARCA': {'about': {}, + 'logo_file': 'logos/SCHD.svg', + 'name': 'Schwab US Dividend Equity ETF', + 'profile': '', + 'related': ['VOO:NYSEARCA']}, + 'SCHW:NYSE': {'about': {'CEO': 'Rick Wurster', + 'Employees': '33.7K', + 'Founded': '1971', + 'Sector': 'Capital market', + 'Website': 'aboutschwab.com'}, + 'logo_file': 'logos/SCHW.svg', + 'name': 'Charles Schwab Corporation Common Stock', + 'profile': 'The Charles Schwab Corporation is an American multinational ' + 'financial services company. It offers banking, commercial banking, ' + 'investing, and related services including consulting, and wealth ' + 'management advisory services to both retail and institutional ' + 'clients. One of the largest banks in the United States by assets, ' + 'as of December 31, 2024, it had $10.10 trillion in client assets, ' + '36.5 million active brokerage accounts, 5.4 million workplace ' + 'retirement plan participant accounts, and 2.0 million banking ' + 'accounts. It also offers a donor advised fund for clients seeking ' + 'to donate securities. It was founded in San Francisco, California, ' + 'and is headquartered in Westlake, Texas. It has over 380 branches, ' + 'primarily in financial centers in the United States and the United ' + 'Kingdom. Founded as First Commander Corporation in 1971 and renamed ' + 'to Charles Schwab & Co. in 1973, the company leveraged deregulation ' + 'of the 1970s to pioneer discount sales of equity securities. After ' + 'a flagship opening in Sacramento, California, the bank expanded ' + 'into Seattle before the 1980s economic expansion financed the ' + "bank's investments in technology, automation, and digital record " + 'keeping.', + 'related': ['NVDA:NASDAQ', + 'VOO:NYSEARCA', + 'AAPL:NASDAQ', + 'NFLX:NASDAQ', + 'VZ:NYSE']}, + 'SHW:NYSE': {'about': {'CEO': 'Heidi G. Petz', + 'Employees': '64.2K', + 'Founded': '1866', + 'Headquarters': 'Cleveland, Ohio, United States', + 'Sector': 'Speciality chemicals', + 'Website': 'sherwin-williams.com'}, + 'logo_file': 'logos/SHW.svg', + 'name': 'Sherwin-Williams Co', + 'profile': 'Sherwin-Williams is an American paints and coatings company based in ' + 'Cleveland, Ohio. It is primarily engaged in the manufacture, ' + 'distribution, and sale of paints, coatings, floor coverings, and ' + 'related products with operations in over 120 countries. It is the ' + 'largest coatings company in the world by revenue. The company is ' + 'ranked 191st on the Fortune 500 and 453rd on the Forbes Global 2000. ' + 'Sherwin-Williams has created more than 35,000 color names for paint ' + 'over its history, and as of 2023, it has nearly 200 distinct shades ' + 'of white classified as either warm or cool.', + 'related': ['XOM:NYSE', 'TSLA:NASDAQ', 'DIS:NYSE', 'UNH:NYSE']}, + 'SLB:NYSE': {'about': {'CEO': 'Olivier Le Peuch', + 'Employees': '109K', + 'Founded': '1926', + 'Headquarters': 'Houston, Texas, United States', + 'Sector': 'Energy', + 'Website': 'slb.com'}, + 'logo_file': 'logos/SLB.svg', + 'name': 'Slb NV', + 'profile': 'SLB, formerly known as Schlumberger, is a multinational oilfield ' + 'services company. Founded in France in 1926, the company is now ' + 'incorporated as SLB N.V. in Willemstad, Curaçao, with principal ' + 'executive offices in four cities: Paris, France; Houston, Texas, ' + 'United States; London, UK; and The Hague, Netherlands. As of 2022, ' + "it is both the world's largest offshore drilling company and the " + "world's largest offshore drilling contractor by revenue. Also known " + 'as SLB Limited, the company trades on the New York Stock Exchange ' + 'and formerly traded on Euronext Paris, the London Stock Exchange and ' + 'SIX Swiss Exchange. In 2022, the Forbes Global 2000 ranked SLB the ' + '349th largest company in the world.', + 'related': ['XOM:NYSE', 'ABT:NYSE']}, + 'SO:NYSE': {'about': {'CEO': 'Christopher C Womack', + 'Employees': '29.7K', + 'Founded': 'Nov 9, 1945', + 'Headquarters': 'Atlanta, Georgia, United States', + 'Sector': 'Public utility', + 'Website': 'southerncompany.com'}, + 'logo_file': 'logos/SO.svg', + 'name': 'Southern Co', + 'profile': 'Southern Company is an American gas and electric utility holding ' + 'company based in the Southern United States. It is headquartered in ' + 'Atlanta, Georgia, with executive offices located in Birmingham, ' + 'Alabama. As of 2021 it is the second largest utility company in the ' + 'U.S. in terms of customer base. Through its subsidiaries it serves 9 ' + 'million gas and electric utility customers in 6 states. Southern ' + "Company's regulated regional electric utilities serve a " + '120,000-square-mile territory with 27,000 miles of distribution ' + 'lines.', + 'related': ['D:NYSE', 'DUK:NYSE', 'NEE:NYSE', 'VZ:NYSE']}, + 'SPG:NYSE': {'about': {'Employees': '3.35K', + 'Founded': '1960', + 'Headquarters': 'Indianapolis, Indiana, United States', + 'Sector': 'Capital market', + 'Website': 'simon.com'}, + 'logo_file': 'logos/SPG.svg', + 'name': 'Simon Property Group Inc', + 'profile': 'Simon Property Group, Inc. is an American real estate investment ' + 'trust that invests in shopping malls, outlet centers, and ' + 'community/lifestyle centers. It is the largest owner of shopping ' + 'malls in the United States and is headquartered in Indianapolis, ' + 'Indiana. As of June 9, 2026, it owned interests in 235 properties.', + 'related': ['O:NYSE', 'PLD:NYSE', 'EQIX:NASDAQ']}, + 'SPGI:NYSE': {'about': {'CEO': 'Martina Cheung', + 'Employees': '44.5K', + 'Founded': '1860', + 'Sector': 'Capital market', + 'Website': 'spglobal.com'}, + 'logo_file': 'logos/SPGI.svg', + 'name': 'S&P Global Inc', + 'profile': 'S&P Global Inc. is an American publicly traded corporation ' + 'headquartered in Manhattan, New York. Its primary areas of business ' + 'are financial information, analytics, and energy and commodities ' + 'intelligence. It is the parent company of S&P Global Ratings, S&P ' + 'Global Energy, S&P Global Market Intelligence, S&P Global Mobility, ' + 'and the Indian credit rating agency CRISIL. It is also the majority ' + 'owner of the S&P Dow Jones Indices joint venture. "S&P" is a ' + 'shortening of "Standard and Poor\'s".', + 'related': ['AMZN:NASDAQ']}, + 'SPY:NYSEARCA': {'about': {}, + 'logo_file': 'logos/SPY.svg', + 'name': 'State Street SPDR S&P 500 ETF Trust', + 'profile': 'The SPDR S&P 500 ETF Trust, commonly known by its ticker symbol ' + 'SPY, is an exchange-traded fund that seeks to track, before ' + 'expenses, the price and yield performance of the S&P 500 by ' + 'holding, as far as practicable, a portfolio of common stocks ' + 'included in the index, with portfolio weights that substantially ' + 'correspond to the index. Launched on January 22, 1993, SPY was ' + 'the first exchange-traded fund listed in the United States. It ' + "is part of the SPDR family of funds. The trust's trustee is " + 'State Street Global Advisors Trust Company, its sponsor is PDR ' + 'Services LLC, and its distributor is ALPS Distributors, Inc. The ' + "fund's gross expense ratio is 0.0945%; its CUSIP is 78462F103 " + 'and its ISIN is US78462F1030.', + 'related': ['VOO:NYSEARCA', 'QQQ:NASDAQ', 'MSFT:NASDAQ', 'TSLA:NASDAQ']}, + 'T:NYSE': {'about': {'CEO': 'John T. Stankey', + 'Employees': '131K', + 'Founded': 'Oct 5, 1983', + 'Headquarters': 'Dallas, Texas, United States', + 'Sector': 'Telecommunications', + 'Website': 'att.com'}, + 'logo_file': 'logos/T.svg', + 'name': 'AT&T Inc', + 'profile': "AT&T Inc., an abbreviation of its predecessor's original name, the " + 'American Telephone and Telegraph Company, is an American multinational ' + 'telecommunications company headquartered at the Whitacre Tower in ' + "Downtown Dallas, Texas. AT&T is the world's third-largest " + 'telecommunications company by revenue, the third-largest wireless ' + 'carrier in the United States behind T-Mobile and Verizon, and the ' + "nation's largest fiber internet provider. On the New York Stock " + 'Exchange, AT&T trades under the ticker symbol "T," and has a market ' + 'capitalization of $186.83 billion. On the Fortune 500 AT&T ranked 37th ' + 'among the largest American businesses and reported revenues of $125.6 ' + 'billion last year. AT&T has over 240 million subscribers worldwide, ' + 'and its operations are split into two main segments: Communications, ' + 'which offers wireless services, broadband options, and business ' + 'solutions, along with Latin America, which provides wireless ' + 'networking in Mexico. Even after spinning off DirecTV, AT&T still ' + 'offers bundling deals with it.', + 'related': ['VZ:NYSE', 'PFE:NYSE', 'CVS:NYSE']}, + 'TJX:NYSE': {'about': {'CEO': 'Ernie Herrman', + 'Employees': '377K', + 'Founded': '1987', + 'Headquarters': 'Framingham, Massachusetts, United States', + 'Sector': 'Consumer', + 'Website': 'tjx.com'}, + 'logo_file': 'logos/TJX.svg', + 'name': 'TJX Companies Inc', + 'profile': 'The TJX Companies, Inc. is an American multinational off-price ' + 'department store corporation, headquartered in Framingham, ' + 'Massachusetts. It was formed as a subsidiary of Zayre Corp. in 1987, ' + 'and became the legal successor to Zayre Corp. following a company ' + 'reorganization in 1989. As of 2019, TJX operates TJ Maxx and TK ' + 'Maxx, its flagship store chains, along with Marshalls, HomeGoods, ' + 'HomeSense, and Sierra in the United States, and HomeSense, ' + 'Marshalls, and Winners in Canada. There are over 4,557 discount ' + 'stores in the TJX portfolio located in nine countries. TJX ranked ' + 'No. 80 in the 2024 Fortune 500 list of the largest United States ' + 'corporations by total revenue. TJX is a publicly listed stock on the ' + 'New York Stock Exchange under the ticker symbol TJX and has a ' + 'capital value of $170 Billion.', + 'related': ['TMO:NYSE', 'AMZN:NASDAQ', 'CAT:NYSE']}, + 'TMO:NYSE': {'about': {'Employees': '125K', + 'Founded': '1956', + 'Headquarters': 'Waltham, Massachusetts, United States', + 'Sector': 'Health care', + 'Website': 'thermofisher.com'}, + 'logo_file': 'logos/TMO.svg', + 'name': 'Thermo Fisher Scientific Inc', + 'profile': '', + 'related': ['DHR:NYSE', 'UNH:NYSE', 'TXN:NASDAQ']}, + 'TMUS:NASDAQ': {'about': {'Employees': '75K', + 'Founded': 'Sep 2, 2001', + 'Headquarters': 'Bellevue, King Country, United States', + 'Sector': 'Telecommunications', + 'Website': 't-mobile.com'}, + 'logo_file': 'logos/TMUS.svg', + 'name': 'T-Mobile Us Inc', + 'profile': 'T-Mobile US, Inc. is an American wireless network operator ' + 'headquartered in Bellevue, Washington. Its majority shareholder ' + 'and namesake is the German telecommunications company Deutsche ' + 'Telekom. T-Mobile is the second largest wireless carrier in the ' + 'United States, with 140 million subscribers as of September 30, ' + '2025. The company was founded in 1994 by John W. Stanton of the ' + 'Western Wireless Corporation as VoiceStream Wireless. Deutsche ' + 'Telekom then gained plurality ownership in 2001 and renamed it ' + 'after its global T-Mobile brand. As of April 2023, the German ' + 'company holds a 51.4% stake in the company. T-Mobile offers ' + 'services under the primary retail brands T-Mobile and Metro by ' + 'T-Mobile. In 2020, T-Mobile expanded through the acquisition of ' + 'Sprint, which also made T-Mobile the operator of Assurance ' + 'Wireless, a service subsidized by the federal Lifeline program. ' + "The company's growth continued in 2024 with the acquisitions of " + 'Mint Mobile and Ultra Mobile, two low-cost mobile virtual network ' + 'operators which remain separate brands. In August 2025, the ' + 'company acquired the wireless operations of UScellular.', + 'related': ['TSLA:NASDAQ', 'AAPL:NASDAQ', 'VZ:NYSE']}, + 'TSLA:NASDAQ': {'about': {'CEO': 'Elon Musk', + 'Employees': '135K', + 'Founded': 'Jul 1, 2003', + 'Sector': 'Car', + 'Website': 'tesla.com'}, + 'logo_file': 'logos/TSLA.svg', + 'name': 'Tesla Inc', + 'profile': 'Tesla, Inc. is an American multinational automotive and clean ' + 'energy company. Headquartered in Austin, Texas, it designs, ' + 'manufactures, and sells battery electric vehicles, stationary ' + 'battery energy storage devices from home to grid-scale, solar ' + 'panels and solar shingles, and related products and services. The ' + 'company was incorporated in July 2003 by Martin Eberhard and Marc ' + 'Tarpenning as Tesla Motors. Its name is a tribute to the inventor ' + 'and electrical engineer Nikola Tesla. In February 2004, Elon Musk ' + "led Tesla's first funding round and became the company's " + 'chairman, subsequently claiming to be a co-founder; in 2008, he ' + 'was named chief executive officer. The company began production ' + 'of its first car model, the Roadster sports car, in 2008; the ' + 'Model S sedan in 2012; the Model X SUV in 2015; the Model 3 sedan ' + 'in 2017; the Model Y crossover in 2020; the Tesla Semi truck in ' + '2022; and the Cybertruck pickup truck in 2023. Tesla is one of ' + "the world's most valuable companies in terms of market " + "capitalization. Starting in July 2020, it has been the world's " + 'most valuable automaker.', + 'related': ['AAPL:NASDAQ', 'AMZN:NASDAQ', 'MSFT:NASDAQ']}, + 'TXN:NASDAQ': {'about': {'CEO': 'Haviv Ilan', + 'Employees': '33K', + 'Founded': '1930', + 'Headquarters': 'Dallas, Texas, United States', + 'Sector': 'Semiconductor', + 'Website': 'ti.com'}, + 'logo_file': 'logos/TXN.svg', + 'name': 'Texas Instruments Inc', + 'profile': 'Texas Instruments Incorporated is an American multinational ' + 'semiconductor company headquartered in Dallas, Texas. It is one of ' + 'the top 10 semiconductor companies worldwide based on sales ' + "volume. The company's focus is on developing analog chips and " + 'embedded processors, which account for more than 80% of its ' + 'revenue. TI also produces digital light processing technology and ' + 'education technology products including calculators, ' + 'microcontrollers, and multi-core processors. Texas Instruments ' + 'emerged in 1951 from the reorganization of Geophysical Service ' + 'Incorporated, a company founded in 1930 that manufactured ' + 'equipment for the seismic industry and defense electronics. TI ' + "produced the world's first commercial silicon transistor in 1954, " + 'and the same year designed and manufactured the first transistor ' + 'radio. Jack Kilby invented the integrated circuit in 1958 while ' + "working at TI's Central Research Labs. TI also invented the " + 'handheld calculator in 1967 and introduced the first single-chip ' + 'microcontroller in 1970, combining all the elements of computing ' + 'onto a single chip.', + 'related': ['INTC:NASDAQ', 'NVDA:NASDAQ', 'QCOM:NASDAQ', 'MU:NASDAQ']}, + 'UNH:NYSE': {'about': {'CEO': 'Stephen J. Hemsley', + 'Employees': '390K', + 'Founded': 'Jan 1977', + 'Sector': 'Health care provider', + 'Website': 'unitedhealthgroup.com'}, + 'logo_file': 'logos/UNH.svg', + 'name': 'UnitedHealth Group Inc', + 'profile': 'UnitedHealth Group Incorporated is an American multinational ' + 'for-profit company based in Eden Prairie, Minnesota, specializing in ' + 'health insurance and health care services. Selling insurance ' + 'products under UnitedHealthcare, and health care services under the ' + "Optum brand, it is the world's seventh-largest company by revenue " + 'and the largest health care company by revenue. The company is ' + 'ranked seventh on the 2025 Fortune Global 500. UnitedHealth Group ' + 'had a market capitalization of more than $300 billion as of year-end ' + '2025. The company is the largest health insurer in the United ' + 'States, insuring over 50 million people, and operates globally. ' + 'UnitedHealth Group has faced numerous investigations, lawsuits, and ' + 'fines, including SEC enforcement for stock option backdating, ' + 'Medicare over-billing, unfair claims practices, mental health ' + 'treatment denials, and anticompetitive behavior.', + 'related': ['MSFT:NASDAQ', 'LLY:NYSE', 'GOOGL:NASDAQ', 'NVDA:NASDAQ']}, + 'UNP:NYSE': {'about': {'CEO': 'Jim Vena', + 'Employees': '28.7K', + 'Founded': 'Jan 30, 1969', + 'Headquarters': 'Omaha, Nebraska, United States', + 'Sector': 'Rail transport', + 'Website': 'up.com'}, + 'logo_file': 'logos/UNP.svg', + 'name': 'Union Pacific Corp', + 'profile': 'The Union Pacific Corporation is an American publicly traded ' + 'railroad holding company serving as the holding company for the ' + 'Union Pacific Railroad. Incorporated in 1969 in Utah, it is ' + 'headquartered in Omaha, Nebraska along with its Union Pacific ' + 'Railroad subsidiary. Along with BNSF Railway, owned by Berkshire ' + 'Hathaway, the companies have a near-duopoly on freight railroad ' + 'transportation west of the Mississippi River. Notable companies ' + 'acquired by Union Pacific and merged into Union Pacific Railroad ' + 'include Missouri Pacific Railroad which included the ' + 'Missouri–Kansas–Texas Railroad, the Chicago and North Western ' + 'Transportation Company, the Western Pacific Railroad, the Denver and ' + 'Rio Grande Western Railroad, the St. Louis Southwestern Railway, the ' + 'SPCSL Corporation, and the Southern Pacific Transportation Company. ' + 'Union Pacific has announced plans to acquire the Norfolk Southern in ' + 'a deal worth $85 billion. If approved by regulators, it would create ' + 'the first transcontinental railroad network in the United States.', + 'related': ['AAPL:NASDAQ', 'LLY:NYSE', 'AXP:NYSE']}, + 'UPS:NYSE': {'about': {'CEO': 'Carol B. Tomé', + 'Employees': '460K', + 'Founded': 'Aug 28, 1907', + 'Headquarters': 'Atlanta, Georgia, United States', + 'Sector': 'Air cargo', + 'Website': 'ups.com'}, + 'logo_file': 'logos/UPS.svg', + 'name': 'United Parcel Service Inc', + 'profile': 'United Parcel Service, Inc. is an American multinational ' + 'shipping-and-receiving and supply chain management company founded ' + 'in 1907. Originally known as the American Messenger Company ' + 'specializing in telegraphs, UPS has expanded to become a Fortune 500 ' + "company and one of the world's largest shipping couriers. UPS today " + 'is primarily known for its ground shipping services as well as the ' + 'UPS Store, a retail chain which assists UPS shipments and provides ' + 'tools for small businesses. UPS offers air shipping on an overnight ' + 'or two-day basis and delivers to post office boxes through UPS Mail ' + 'Innovations and UPS Ground Saver. UPS is the largest courier company ' + 'in the world by revenue, with annual revenues around US$85 billion ' + "in 2020, ahead of competitors DHL and FedEx. UPS's main " + 'international hub, UPS Worldport in Louisville, Kentucky, is the ' + 'fifth busiest airport in the world by cargo traffic based on ' + 'preliminary statistics from ACI, and the third busiest in the U.S. ' + 'The company is one of the largest private employers in the United ' + 'States. As of 2023, UPS is third in U.S. parcel volumes shipped ' + 'since 2015, trailing the United States Postal Service and Amazon.', + 'related': ['VZ:NYSE', 'PFE:NYSE', 'BAC:NYSE', 'C:NYSE']}, + 'V:NYSE': {'about': {'CEO': 'Ryan McInerney', + 'Employees': '34.1K', + 'Founded': 'Sep 18, 1958', + 'Headquarters': 'San Francisco, California, United States', + 'Sector': 'Financial services', + 'Website': 'visa.com'}, + 'logo_file': 'logos/V.svg', + 'name': 'Visa Inc', + 'profile': 'Visa Inc. is an American multinational payment card services ' + 'corporation headquartered in San Francisco, California. It facilitates ' + 'electronic funds transfers worldwide, most commonly through ' + 'Visa-branded credit cards, debit cards and prepaid cards. Visa does ' + 'not issue cards, extend credit, or set rates and fees for consumers; ' + 'rather, Visa provides financial institutions with Visa-branded payment ' + 'products that they then use to offer credit, debit, prepaid and cash ' + "access programs to their customers. In 2025, Visa's global network " + 'processed 257.5 billion transactions worth US$14.2 trillion. Visa was ' + 'founded in 1958 by Bank of America as the BankAmericard credit card ' + 'program. In response to competitor Master Charge, BofA began to ' + 'license the BankAmericard program to other financial institutions in ' + '1966. By 1970, BofA gave up direct control of the BankAmericard ' + 'program, forming a cooperative with the other various BankAmericard ' + 'issuer banks to take over its management. It was then renamed Visa in ' + '1976.', + 'related': ['MA:NYSE', + 'MSFT:NASDAQ', + 'XOM:NYSE', + 'CRM:NYSE', + 'JPM:NYSE', + 'INTU:NASDAQ']}, + 'VIG:NYSEARCA': {'about': {}, + 'logo_file': 'logos/VIG.svg', + 'name': 'Vanguard Dividend Appreciation Index Fund ETF', + 'profile': '', + 'related': ['SCHD:NYSEARCA', 'VTI:NYSEARCA']}, + 'VOO:NYSEARCA': {'about': {}, + 'logo_file': 'logos/VOO.svg', + 'name': 'Vanguard S&P 500 ETF', + 'profile': '', + 'related': ['VTI:NYSEARCA', 'SCHD:NYSEARCA']}, + 'VTI:NYSEARCA': {'about': {}, + 'logo_file': 'logos/VTI.svg', + 'name': 'Vanguard Total Stock Market Index Fund ETF', + 'profile': '', + 'related': ['VOO:NYSEARCA']}, + 'VZ:NYSE': {'about': {'CEO': 'Dan Schulman', + 'Employees': '89.9K', + 'Founded': 'Oct 7, 1983', + 'Headquarters': 'New York, New York, United States', + 'Sector': 'Telecommunications', + 'Website': 'verizon.com'}, + 'logo_file': 'logos/VZ.svg', + 'name': 'Verizon Communications Inc', + 'profile': 'Verizon Communications, Inc. is an American telecommunications ' + "company headquartered in New York City. It is the world's " + 'second-largest telecommunications company by revenue and its mobile ' + 'network is the largest wireless carrier in the United States, with ' + '146.8 million subscribers as of March 31, 2026. The company was ' + 'formed in 1983 as Bell Atlantic as a result of the breakup of the ' + 'Bell System into seven companies, each a Regional Bell Operating ' + 'Company, commonly referred to as "Baby Bells." The company was ' + 'originally headquartered in Philadelphia and operated in the states ' + 'of Pennsylvania, New Jersey, Delaware, Maryland, Virginia, and West ' + 'Virginia. In 1997, Bell Atlantic expanded into New York and the New ' + 'England states by merging with fellow Baby Bell NYNEX. While Bell ' + 'Atlantic was the surviving company, the merged company moved its ' + "headquarters from Philadelphia to NYNEX's old headquarters in New " + 'York City. In 2000, Bell Atlantic acquired GTE, which operated ' + 'telecommunications companies across most of the rest of the country ' + "not already in Bell Atlantic's footprint. Bell Atlantic, the " + 'surviving entity, changed its name to Verizon, a portmanteau of ' + 'veritas and horizon.', + 'related': ['CMCSA:NASDAQ', 'PFE:NYSE', 'GS:NYSE', 'T:NYSE']}, + 'WBD:NASDAQ': {'about': {'CEO': 'David Zaslav', + 'Employees': '35.5K', + 'Founded': 'Apr 8, 2022', + 'Sector': 'Entertainment'}, + 'logo_file': 'logos/WBD.svg', + 'name': 'Warner Bros Discovery Inc', + 'profile': 'Warner Bros. Discovery, Inc. is an American multinational mass ' + 'media and entertainment conglomerate headquartered in New York ' + 'City. It was formed through the spin-off of WarnerMedia by AT&T, ' + 'and its merger with Discovery, Inc. on April 8, 2022. On April 23, ' + '2026, the company agreed to be sold to Paramount Skydance in a ' + 'US$110 billion transaction, subject to approval by U.S. ' + 'regulators. On June 12, 2026, the Paramount-WBD deal was approved ' + 'by the U.S. Department of Justice. However, on July 24, 2026, the ' + 'proposed merger, which is facing widespread anti-trust legal ' + 'action, would be delayed until June 1, 2027. Warner Bros. ' + 'Discovery operates via two divisions: Streaming & Studios and ' + 'Global Linear Networks. Streaming & Studios includes the flagship ' + 'Warner Bros. studios, HBO, DC Entertainment, and the Warner Bros. ' + "Discovery's streaming services. Global Linear Networks largely " + 'includes advertising-supported cable networks. Those networks were ' + 'inherited from its predecessors Discovery, Scripps Networks ' + 'Interactive, and Turner Broadcasting System. Warner Bros. ' + 'Discovery International is also included in the division, which ' + 'manages broadcasting operations outside of the United States.', + 'related': ['AAPL:NASDAQ', 'XOM:NYSE', 'AMAT:NASDAQ', 'CMCSA:NASDAQ']}, + 'WFC:NYSE': {'about': {'CEO': 'Charles W. Scharf', + 'Employees': '197K', + 'Founded': 'Mar 18, 1852', + 'Headquarters': 'San Francisco, California, United States', + 'Sector': 'Bank', + 'Website': 'wellsfargo.com'}, + 'logo_file': 'logos/WFC.svg', + 'name': 'Wells Fargo & Co', + 'profile': 'Wells Fargo & Company is an American multinational financial ' + 'services company. The company operates in 35 countries and serves ' + 'more than 70 million customers worldwide. It is a systemically ' + 'important financial institution according to the Financial Stability ' + 'Board, and is considered one of the "Big Four Banks" in the United ' + 'States, alongside JPMorgan Chase, Bank of America, and Citigroup. ' + "The company's primary subsidiary is Wells Fargo Bank, N.A., a " + 'national bank that designates its Sioux Falls, South Dakota, site as ' + 'its main office. It is the fourth-largest bank in the United States ' + 'by total assets and is also one of the largest as ranked by bank ' + 'deposits and market capitalization. It has 8,050 branches and 13,000 ' + 'automated teller machines and 2,000 stand-alone mortgage branches. ' + 'It is the second-largest retail mortgage originator in the United ' + 'States, originating one out of every four home loans, and services ' + '$1.8 trillion in home mortgages, one of the largest servicing ' + 'portfolios in the U.S. It is one of the most valuable bank brands. ' + 'Wells Fargo is ranked 47th on the Fortune 500 list of the largest ' + 'companies in the U.S.', + 'related': ['C:NYSE', 'BAC:NYSE', 'JPM:NYSE']}, + 'WMT:NASDAQ': {'about': {'CEO': 'John Furner', + 'Employees': '2.1M', + 'Founded': 'Jul 2, 1962', + 'Headquarters': 'Bentonville, Arkansas, United States', + 'Sector': 'Consumer', + 'Website': 'stock.walmart.com'}, + 'logo_file': 'logos/WMT.svg', + 'name': 'Walmart Inc', + 'profile': 'Walmart Inc. is an American multinational omnichannel retail ' + 'corporation that operates a chain of hypermarkets, discount ' + 'department stores, grocery stores, pharmacies, and gas stations in ' + 'the United States and 19 other countries. It is headquartered in ' + 'Bentonville, Arkansas. The company was founded in 1962 by brothers ' + 'Sam Walton and James "Bud" Walton in nearby Rogers, Arkansas. It ' + "also owns and operates Sam's Club retail warehouses. As of 2026, " + 'Walmart is the second-largest company by revenue globally and the ' + 'largest private employer in the world, with 2.1 million employees. ' + 'The company is ranked one in the Fortune 500 and Fortune Global ' + '500. In February 2026, Walmart became the first traditional ' + 'retailer to be valued at over $1 trillion and is known for product ' + 'innovation. It is a publicly traded family-owned business, as the ' + "company is controlled by the Walton family. Sam Walton's heirs own " + 'over 50 percent of Walmart through both their holding company ' + 'Walton Enterprises and their individual holdings. Walmart was ' + 'listed on the New York Stock Exchange in 1972 and switched to the ' + 'Nasdaq in December 2025.', + 'related': ['AMZN:NASDAQ', + 'AAPL:NASDAQ', + 'MSFT:NASDAQ', + 'TSLA:NASDAQ', + 'NFLX:NASDAQ']}, + 'XLE:NYSEARCA': {'about': {}, + 'logo_file': 'logos/XLE.svg', + 'name': 'State Street Energy Select Sector SPDR ETF', + 'profile': '', + 'related': ['XLF:NYSEARCA', 'SPY:NYSEARCA']}, + 'XLF:NYSEARCA': {'about': {}, + 'logo_file': 'logos/XLF.svg', + 'name': 'State Street Financial Sel Sec SPDR ETF', + 'profile': '', + 'related': ['XLK:NYSEARCA']}, + 'XLK:NYSEARCA': {'about': {}, + 'logo_file': 'logos/XLK.svg', + 'name': 'State Street Technology Select Sector SPDR ETF', + 'profile': '', + 'related': ['QQQ:NASDAQ', 'XLF:NYSEARCA']}, + 'XOM:NYSE': {'about': {'Employees': '57.9K', + 'Founded': '1870', + 'Sector': 'Energy', + 'Website': 'corporate.exxonmobil.com'}, + 'logo_file': 'logos/XOM.svg', + 'name': 'Exxonmobil Holdings Corp', + 'profile': '', + 'related': ['CVX:NYSE', 'AAPL:NASDAQ', 'TSLA:NASDAQ', 'WMT:NASDAQ']}} + +PUBLISHERS = [{'domain': '247wallst.com', 'file': 'publishers/247wallst_com.png', 'name': '24/7 Wall St.'}, + {'domain': '9to5mac.com', 'file': 'publishers/9to5mac_com.png', 'name': '9to5Mac'}, + {'domain': 'finance.yahoo.com', + 'file': 'publishers/finance_yahoo_com.png', + 'name': 'Yahoo Finance'}, + {'domain': 'financialpost.com', + 'file': 'publishers/financialpost_com.png', + 'name': 'Financial Post'}, + {'domain': 'fortune.com', 'file': 'publishers/fortune_com.png', 'name': 'Fortune'}, + {'domain': 'markets.ft.com', + 'file': 'publishers/markets_ft_com.png', + 'name': 'Financial Times'}, + {'domain': 'seekingalpha.com', + 'file': 'publishers/seekingalpha_com.png', + 'name': 'Seeking Alpha'}, + {'domain': 'simplywall.st', + 'file': 'publishers/simplywall_st.png', + 'name': 'Simply Wall Street'}, + {'domain': 'stocktwits.com', 'file': 'publishers/stocktwits_com.png', 'name': 'Stocktwits'}, + {'domain': 'techcrunch.com', 'file': 'publishers/techcrunch_com.png', 'name': 'TechCrunch'}, + {'domain': 'www.axios.com', 'file': 'publishers/www_axios_com.png', 'name': 'Axios'}, + {'domain': 'www.barrons.com', 'file': 'publishers/www_barrons_com.png', 'name': "Barron's"}, + {'domain': 'www.benzinga.com', 'file': 'publishers/www_benzinga_com.png', 'name': 'Benzinga'}, + {'domain': 'www.bloomberg.com', + 'file': 'publishers/www_bloomberg_com.png', + 'name': 'Bloomberg.com'}, + {'domain': 'www.businessinsider.com', + 'file': 'publishers/www_businessinsider_com.png', + 'name': 'Business Insider'}, + {'domain': 'www.cnbc.com', 'file': 'publishers/www_cnbc_com.png', 'name': 'CNBC'}, + {'domain': 'www.cnn.com', 'file': 'publishers/www_cnn_com.png', 'name': 'CNN'}, + {'domain': 'www.fool.com', 'file': 'publishers/www_fool_com.png', 'name': 'The Motley Fool'}, + {'domain': 'www.forbes.com', 'file': 'publishers/www_forbes_com.png', 'name': 'Forbes'}, + {'domain': 'www.ft.com', 'file': 'publishers/www_ft_com.png', 'name': 'Financial Times'}, + {'domain': 'www.gurufocus.com', + 'file': 'publishers/www_gurufocus_com.png', + 'name': 'GuruFocus'}, + {'domain': 'www.investing.com', + 'file': 'publishers/www_investing_com.png', + 'name': 'Investing'}, + {'domain': 'www.investopedia.com', + 'file': 'publishers/www_investopedia_com.png', + 'name': 'Investopedia'}, + {'domain': 'www.investors.com', + 'file': 'publishers/www_investors_com.png', + 'name': "Investor's Business Daily"}, + {'domain': 'www.marketbeat.com', + 'file': 'publishers/www_marketbeat_com.png', + 'name': 'MarketBeat'}, + {'domain': 'www.marketwatch.com', + 'file': 'publishers/www_marketwatch_com.png', + 'name': 'MarketWatch'}, + {'domain': 'www.morningstar.com', + 'file': 'publishers/www_morningstar_com.png', + 'name': 'Morningstar'}, + {'domain': 'www.nasdaq.com', 'file': 'publishers/www_nasdaq_com.png', 'name': 'Nasdaq'}, + {'domain': 'www.nytimes.com', + 'file': 'publishers/www_nytimes_com.png', + 'name': 'The New York Times'}, + {'domain': 'www.phonearena.com', + 'file': 'publishers/www_phonearena_com.png', + 'name': 'PhoneArena'}, + {'domain': 'www.proactiveinvestors.com', + 'file': 'publishers/www_proactiveinvestors_com.png', + 'name': 'Proactive financial news'}, + {'domain': 'www.reuters.com', 'file': 'publishers/www_reuters_com.png', 'name': 'Reuters'}, + {'domain': 'www.scmp.com', + 'file': 'publishers/www_scmp_com.png', + 'name': 'South China Morning Post'}, + {'domain': 'www.thestreet.com', + 'file': 'publishers/www_thestreet_com.png', + 'name': 'TheStreet'}, + {'domain': 'www.theverge.com', 'file': 'publishers/www_theverge_com.png', 'name': 'The Verge'}, + {'domain': 'www.tipranks.com', 'file': 'publishers/www_tipranks_com.png', 'name': 'TipRanks'}, + {'domain': 'www.tomshardware.com', + 'file': 'publishers/www_tomshardware_com.png', + 'name': "Tom's Hardware"}, + {'domain': 'www.usatoday.com', 'file': 'publishers/www_usatoday_com.png', 'name': 'USA Today'}, + {'domain': 'www.washingtonpost.com', + 'file': 'publishers/www_washingtonpost_com.png', + 'name': 'The Washington Post'}, + {'domain': 'www.wsj.com', 'file': 'publishers/www_wsj_com.png', 'name': 'WSJ'}, + {'domain': 'www.zacks.com', + 'file': 'publishers/www_zacks_com.png', + 'name': 'Zacks Investment Research'}] + +ANCHORS = {'.DJI:INDEXDJX': {'price': 51791.37}, + '.INX:INDEXSP': {'price': 7406.3}, + '.IXIC:INDEXNASDAQ': {'price': 25107.38}, + '000001:SHA': {'price': 3853.63}, + 'AAPL:NASDAQ': {'avg_volume': 47490000, 'mkt_cap': 4727095100000.0, 'price': 321.79}, + 'ABBV:NYSE': {'avg_volume': 4200000, 'mkt_cap': 457615800000.0, 'price': 258.54}, + 'ABNB:NASDAQ': {'avg_volume': 2410000, 'mkt_cap': 57774643200.0, 'price': 138.24}, + 'ABT:NYSE': {'avg_volume': 10370000, 'mkt_cap': 176610000000.0, 'price': 101.5}, + 'ACN:NYSE': {'avg_volume': 6570000, 'mkt_cap': 87201450000.0, 'price': 142.5}, + 'ADA-USD': {'mkt_cap': 27000000000.0, 'price': 0.78}, + 'ADBE:NASDAQ': {'avg_volume': 4720000, 'mkt_cap': 86058750000.0, 'price': 216.5}, + 'AEP:NASDAQ': {'avg_volume': 3180000, 'mkt_cap': 73688817300.0, 'price': 135.43}, + 'AMAT:NASDAQ': {'avg_volume': 6260000, 'mkt_cap': 441608491600.0, 'price': 556.21}, + 'AMD:NASDAQ': {'avg_volume': 27200000, 'mkt_cap': 891479600000.0, 'price': 546.92}, + 'AMGN:NASDAQ': {'avg_volume': 2490000, 'mkt_cap': 201166108300.0, 'price': 372.73}, + 'AMT:NYSE': {'avg_volume': 3340000, 'mkt_cap': 77584661700.0, 'price': 166.53}, + 'AMZN:NASDAQ': {'avg_volume': 35020000, 'mkt_cap': 2521928800000.0, 'price': 234.38}, + 'APD:NYSE': {'avg_volume': 734710, 'mkt_cap': 65639383600.0, 'price': 294.77}, + 'ARKK:BATS': {'avg_volume': 524780, 'price': 73.78}, + 'AUD-USD': {'price': 0.66}, + 'AVAX-USD': {'mkt_cap': 13000000000.0, 'price': 32.0}, + 'AVGO:NASDAQ': {'avg_volume': 15150000, 'mkt_cap': 1845356800000.0, 'price': 387.68}, + 'AXP:NYSE': {'avg_volume': 6370000, 'mkt_cap': 222521459600.0, 'price': 326.12}, + 'BA:NYSE': {'avg_volume': 3740000, 'mkt_cap': 165613947000.0, 'price': 210.09}, + 'BAC:NYSE': {'avg_volume': 29040000, 'mkt_cap': 436863000000.0, 'price': 61.53}, + 'BKNG:NASDAQ': {'avg_volume': 5010000, 'mkt_cap': 135294048000.0, 'price': 174.6}, + 'BLK:NYSE': {'avg_volume': 506290, 'mkt_cap': 161060500000.0, 'price': 1039.1}, + 'BNB-USD': {'mkt_cap': 98000000000.0, 'price': 690.0}, + 'BTC-USD': {'mkt_cap': 2140000000000.0, 'price': 108000.0}, + 'C:NYSE': {'avg_volume': 10820000, 'mkt_cap': 226130400000.0, 'price': 132.24}, + 'CAT:NYSE': {'avg_volume': 1980000, 'mkt_cap': 412272800000.0, 'price': 895.0}, + 'CL:NYSE': {'avg_volume': 3190000, 'mkt_cap': 72017100000.0, 'price': 90.0}, + 'CL=F': {'price': 72.4}, + 'CMCSA:NASDAQ': {'avg_volume': 48230000, 'mkt_cap': 75828000000.0, 'price': 21.3}, + 'COP:NYSE': {'avg_volume': 4390000, 'mkt_cap': 146729400000.0, 'price': 120.27}, + 'COST:NASDAQ': {'avg_volume': 1590000, 'mkt_cap': 410223434800.0, 'price': 925.01}, + 'CRM:NYSE': {'avg_volume': 10900000, 'mkt_cap': 131351220000.0, 'price': 160.38}, + 'CSCO:NASDAQ': {'avg_volume': 19130000, 'mkt_cap': 442737800000.0, 'price': 112.37}, + 'CVS:NYSE': {'avg_volume': 5370000, 'mkt_cap': 137792000000.0, 'price': 107.65}, + 'CVX:NYSE': {'avg_volume': 6700000, 'mkt_cap': 386975400000.0, 'price': 194.46}, + 'D:NYSE': {'avg_volume': 3480000, 'mkt_cap': 63034481700.0, 'price': 71.67}, + 'DAX:INDEXDB': {'price': 24830.41}, + 'DE:NYSE': {'avg_volume': 960400, 'mkt_cap': 165259967400.0, 'price': 612.21}, + 'DHR:NYSE': {'avg_volume': 6850000, 'mkt_cap': 135733309200.0, 'price': 193.08}, + 'DIS:NYSE': {'avg_volume': 12150000, 'mkt_cap': 162272400000.0, 'price': 93.26}, + 'DOGE-USD': {'mkt_cap': 31000000000.0, 'price': 0.21}, + 'DOW:NYSE': {'avg_volume': 10430000, 'mkt_cap': 22061851400.0, 'price': 30.61}, + 'DUK:NYSE': {'avg_volume': 2860000, 'mkt_cap': 100833464000.0, 'price': 129.34}, + 'EA:NASDAQ': {'avg_volume': 2029999, 'mkt_cap': 52464422500.0, 'price': 209.23}, + 'ECL:NYSE': {'avg_volume': 1160000, 'mkt_cap': 74229800000.0, 'price': 263.75}, + 'EOG:NYSE': {'avg_volume': 1980000, 'mkt_cap': 76698720000.0, 'price': 144.0}, + 'EQIX:NASDAQ': {'avg_volume': 783120, 'mkt_cap': 104475069400.0, 'price': 1059.37}, + 'ES=F': {'price': 7420.0}, + 'ETH-USD': {'mkt_cap': 470000000000.0, 'price': 3900.0}, + 'EUR-GBP': {'price': 0.85}, + 'EUR-USD': {'price': 1.08}, + 'EXC:NASDAQ': {'avg_volume': 5730000, 'mkt_cap': 48256200000.0, 'price': 47.31}, + 'F:NYSE': {'avg_volume': 76550000, 'mkt_cap': 55404700000.0, 'price': 14.17}, + 'FCX:NYSE': {'avg_volume': 11940000, 'mkt_cap': 90086400000.0, 'price': 62.56}, + 'GBP-USD': {'price': 1.27}, + 'GC=F': {'price': 3120.0}, + 'GE:NYSE': {'avg_volume': 2960000, 'mkt_cap': 364249600000.0, 'price': 350.24}, + 'GILD:NASDAQ': {'avg_volume': 5530000, 'mkt_cap': 162353200000.0, 'price': 130.93}, + 'GIS:NYSE': {'avg_volume': 10580000, 'mkt_cap': 19106818000.0, 'price': 35.8}, + 'GM:NYSE': {'avg_volume': 5610000, 'mkt_cap': 70669823000.0, 'price': 80.54}, + 'GOOGL:NASDAQ': {'avg_volume': 31810000, 'mkt_cap': 1862757000000.0, 'price': 318.42}, + 'GS:NYSE': {'avg_volume': 1380000, 'mkt_cap': 317135750000.0, 'price': 1075.0}, + 'HD:NYSE': {'avg_volume': 3870000, 'mkt_cap': 324133798400.0, 'price': 325.07}, + 'HON:NASDAQ': {'avg_volume': 3820000, 'mkt_cap': 78222158700.0, 'price': 246.89}, + 'HSI:INDEXHANGSENG': {'price': 24932.5}, + 'I:INDEXBME': {'price': 19267.0}, + 'IBM:NYSE': {'avg_volume': 10580000, 'mkt_cap': 196700179200.0, 'price': 209.28}, + 'INTC:NASDAQ': {'avg_volume': 181130000, 'mkt_cap': 504810800000.0, 'price': 100.36}, + 'INTU:NASDAQ': {'avg_volume': 3460000, 'mkt_cap': 78407505600.0, 'price': 286.64}, + 'IVV:NYSEARCA': {'avg_volume': 1030000, 'price': 741.91}, + 'IWM:NYSEARCA': {'avg_volume': 3700000, 'price': 293.41}, + 'JNJ:NYSE': {'avg_volume': 6350000, 'mkt_cap': 630046300000.0, 'price': 261.43}, + 'JPM:NYSE': {'avg_volume': 7520000, 'mkt_cap': 939715200000.0, 'price': 350.64}, + 'KMB:NASDAQ': {'avg_volume': 2600000, 'mkt_cap': 35773173800.0, 'price': 107.77}, + 'KO:NYSE': {'avg_volume': 11980000, 'mkt_cap': 350751000000.0, 'price': 81.57}, + 'LIN:NASDAQ': {'avg_volume': 1330000, 'mkt_cap': 233821170000.0, 'price': 505.45}, + 'LINK-USD': {'mkt_cap': 13000000000.0, 'price': 21.0}, + 'LLY:NYSE': {'avg_volume': 1980000, 'mkt_cap': 1124767169000.0, 'price': 1194.35}, + 'LMT:NYSE': {'avg_volume': 1740000, 'mkt_cap': 131094110400.0, 'price': 568.59}, + 'LOW:NYSE': {'avg_volume': 3430000, 'mkt_cap': 113599846000.0, 'price': 202.6}, + 'LRCX:NASDAQ': {'avg_volume': 9280000, 'mkt_cap': 390775000000.0, 'price': 312.62}, + 'MA:NYSE': {'avg_volume': 2550000, 'mkt_cap': 466111678400.0, 'price': 531.46}, + 'MCD:NYSE': {'avg_volume': 3700000, 'mkt_cap': 187425432900.0, 'price': 263.79}, + 'MDLZ:NASDAQ': {'avg_volume': 8490000, 'mkt_cap': 76851200000.0, 'price': 60.04}, + 'META:NASDAQ': {'avg_volume': 11500000, 'mkt_cap': 1331660000000.0, 'price': 605.3}, + 'MMM:NYSE': {'avg_volume': 4099999, 'mkt_cap': 87832273200.0, 'price': 170.31}, + 'MPC:NYSE': {'avg_volume': 2000000, 'mkt_cap': 90997698000.0, 'price': 311.7}, + 'MRK:NYSE': {'avg_volume': 6150000, 'mkt_cap': 324656800000.0, 'price': 131.44}, + 'MS:NYSE': {'avg_volume': 4090000, 'mkt_cap': 341912000000.0, 'price': 216.4}, + 'MSFT:NASDAQ': {'avg_volume': 27660000, 'mkt_cap': 2875781500000.0, 'price': 387.05}, + 'MU:NASDAQ': {'avg_volume': 40800000, 'mkt_cap': 1083703900000.0, 'price': 959.03}, + 'NEE:NYSE': {'avg_volume': 11650000, 'mkt_cap': 190001900000.0, 'price': 90.91}, + 'NEM:NYSE': {'avg_volume': 9270000, 'mkt_cap': 100301800000.0, 'price': 93.74}, + 'NFLX:NASDAQ': {'avg_volume': 37590000, 'mkt_cap': 288246400000.0, 'price': 69.29}, + 'NG=F': {'price': 3.42}, + 'NI225:INDEXNIKKEI': {'price': 65584.25}, + 'NKE:NYSE': {'avg_volume': 26180000, 'mkt_cap': 49428000000.0, 'price': 41.19}, + 'NOW:NYSE': {'avg_volume': 29630000, 'mkt_cap': 97767600000.0, 'price': 94.92}, + 'NVDA:NASDAQ': {'avg_volume': 114840000, 'mkt_cap': 5020290000000.0, 'price': 207.45}, + 'O:NYSE': {'avg_volume': 3590000, 'mkt_cap': 60518601000.0, 'price': 64.9}, + 'ORCL:NYSE': {'avg_volume': 46410000, 'mkt_cap': 352713600000.0, 'price': 122.47}, + 'OXY:NYSE': {'avg_volume': 8130000, 'mkt_cap': 56992872000.0, 'price': 57.3}, + 'PANW:NASDAQ': {'avg_volume': 5130000, 'mkt_cap': 267393350000.0, 'price': 328.09}, + 'PEP:NASDAQ': {'avg_volume': 7970000, 'mkt_cap': 184320800000.0, 'price': 135.53}, + 'PFE:NYSE': {'avg_volume': 28910000, 'mkt_cap': 140334000000.0, 'price': 24.62}, + 'PG:NYSE': {'avg_volume': 6620000, 'mkt_cap': 341228500000.0, 'price': 146.45}, + 'PLD:NYSE': {'avg_volume': 6400000, 'mkt_cap': 135282534000.0, 'price': 145.1}, + 'PM:NYSE': {'avg_volume': 4440000, 'mkt_cap': 300440400000.0, 'price': 192.59}, + 'PSA:NYSE': {'avg_volume': 1360000, 'mkt_cap': 59153320800.0, 'price': 317.21}, + 'PSX:NYSE': {'avg_volume': 1660000, 'mkt_cap': 82677837400.0, 'price': 206.21}, + 'PX1:INDEXEURO': {'price': 8284.68}, + 'QCOM:NASDAQ': {'avg_volume': 10000000, 'mkt_cap': 178174500000.0, 'price': 169.69}, + 'QQQ:NASDAQ': {'avg_volume': 42880000, 'price': 690.41}, + 'RTX:NYSE': {'avg_volume': 5880000, 'mkt_cap': 284620500000.0, 'price': 210.83}, + 'RUT:INDEXRUSSELL': {'price': 2949.67}, + 'SBUX:NASDAQ': {'avg_volume': 6400000, 'mkt_cap': 118434600000.0, 'price': 103.89}, + 'SCHD:NYSEARCA': {'avg_volume': 3160000, 'price': 32.85}, + 'SCHW:NYSE': {'avg_volume': 5180000, 'mkt_cap': 176801400000.0, 'price': 101.61}, + 'SENSEX:INDEXBOM': {'price': 75708.19}, + 'SHW:NYSE': {'avg_volume': 2020000, 'mkt_cap': 77262446400.0, 'price': 313.26}, + 'SI=F': {'price': 38.5}, + 'SIXB:INDEXCBOE': {'price': 1069.06}, + 'SIXC:INDEXCBOE': {'price': 550.58}, + 'SIXE:INDEXCBOE': {'price': 1249.6}, + 'SIXI:INDEXCBOE': {'price': 1835.27}, + 'SIXM:INDEXCBOE': {'price': 688.93}, + 'SIXR:INDEXCBOE': {'price': 842.79}, + 'SIXRE:INDEXCBOE': {'price': 220.82}, + 'SIXT:INDEXCBOE': {'price': 3585.52}, + 'SIXU:INDEXCBOE': {'price': 936.82}, + 'SIXV:INDEXCBOE': {'price': 1634.29}, + 'SIXY:INDEXCBOE': {'price': 2203.81}, + 'SLB:NYSE': {'avg_volume': 28900000, 'mkt_cap': 75105000000.0, 'price': 50.07}, + 'SO:NYSE': {'avg_volume': 4840000, 'mkt_cap': 109621300000.0, 'price': 97.01}, + 'SOL-USD': {'mkt_cap': 85000000000.0, 'price': 178.0}, + 'SPG:NYSE': {'avg_volume': 983820, 'mkt_cap': 73439691600.0, 'price': 226.47}, + 'SPGI:NYSE': {'avg_volume': 1510000, 'mkt_cap': 125654960000.0, 'price': 424.51}, + 'SPY:NYSEARCA': {'avg_volume': 8029999, 'price': 738.47}, + 'SX5E:INDEXSTOXX': {'price': 6208.99}, + 'T:NYSE': {'avg_volume': 80670000, 'mkt_cap': 157550000000.0, 'price': 23.0}, + 'TJX:NYSE': {'avg_volume': 4250000, 'mkt_cap': 168157000000.0, 'price': 152.87}, + 'TMO:NYSE': {'avg_volume': 2940000, 'mkt_cap': 211823400000.0, 'price': 570.0}, + 'TMUS:NASDAQ': {'avg_volume': 5630000, 'mkt_cap': 185932800000.0, 'price': 172.16}, + 'TSLA:NASDAQ': {'avg_volume': 62760000, 'mkt_cap': 1205907200000.0, 'price': 320.72}, + 'TXN:NASDAQ': {'avg_volume': 8270000, 'mkt_cap': 255835399900.0, 'price': 281.11}, + 'UKX:INDEXFTSE': {'price': 10638.86}, + 'UNH:NYSE': {'avg_volume': 3750000, 'mkt_cap': 386177453600.0, 'price': 425.24}, + 'UNP:NYSE': {'avg_volume': 3660000, 'mkt_cap': 180778757900.0, 'price': 304.49}, + 'UPS:NYSE': {'avg_volume': 3880000, 'mkt_cap': 85602862800.0, 'price': 114.66}, + 'USD-CAD': {'price': 1.37}, + 'USD-CHF': {'price': 0.88}, + 'USD-CNY': {'price': 7.19}, + 'USD-INR': {'price': 84.6}, + 'USD-JPY': {'price': 152.4}, + 'USD-MXN': {'price': 18.9}, + 'USDT-USD': {'mkt_cap': 140000000000.0, 'price': 1.0}, + 'V:NYSE': {'avg_volume': 4480000, 'mkt_cap': 586760200000.0, 'price': 353.47}, + 'VIG:NYSEARCA': {'avg_volume': 98490, 'price': 237.54}, + 'VIX:INDEXCBOE': {'price': 18.96}, + 'VOO:NYSEARCA': {'avg_volume': 992930, 'price': 678.9}, + 'VTI:NYSEARCA': {'avg_volume': 411500, 'price': 364.9}, + 'VZ:NYSE': {'avg_volume': 40760000, 'mkt_cap': 184254400000.0, 'price': 44.08}, + 'WBD:NASDAQ': {'avg_volume': 37670000, 'mkt_cap': 65787100000.0, 'price': 26.21}, + 'WFC:NYSE': {'avg_volume': 11870000, 'mkt_cap': 263007000000.0, 'price': 85.95}, + 'WMT:NASDAQ': {'avg_volume': 18410000, 'mkt_cap': 860635200000.0, 'price': 108.12}, + 'XLE:NYSEARCA': {'avg_volume': 9630000, 'price': 59.4}, + 'XLF:NYSEARCA': {'avg_volume': 6770000, 'price': 55.83}, + 'XLK:NYSEARCA': {'avg_volume': 2150000, 'price': 177.76}, + 'XOM:NYSE': {'avg_volume': 12090000, 'mkt_cap': 649938600000.0, 'price': 156.99}, + 'XRP-USD': {'mkt_cap': 135000000000.0, 'price': 2.35}, + 'ZC=F': {'price': 4.35}} diff --git a/sites/google_finance/_content.py b/sites/google_finance/_content.py new file mode 100644 index 00000000..366748a2 --- /dev/null +++ b/sites/google_finance/_content.py @@ -0,0 +1,278 @@ +"""Editorial content for the Google Finance mirror: market summaries and news. + +Headlines and bodies are generated deterministically from templates so the +corpus is reproducible. They stay qualitative on purpose — no article restates +a key statistic that a benchmark task might ask for, which would turn a +detail-page lookup into a headline skim (leak archetype 4, "pre-bundled answer +sentence"). +""" +from datetime import timedelta + +from _market_data import MARKET_DATE, rng_for + +# -------------------------------------------------------------------------- +# Home-page market summaries (the "US market summary" accordion) +# -------------------------------------------------------------------------- + +MARKET_SUMMARIES = { + "us": [ + ("Wall Street finishes mixed to close out a volatile week", + "Major US stock indexes ended a fragmented session on Friday to wrap " + "a choppy week. Large-cap benchmarks drifted in opposite directions " + "as investors weighed a heavy earnings calendar against a firmer " + "rates backdrop. Breadth stayed narrow: a handful of megacap names " + "again accounted for most of the index-level movement, while the " + "average constituent traded closer to flat.\n\n" + "Sector performance was uneven. Rate-sensitive groups held up better " + "than cyclicals, and defensives attracted flows late in the session " + "as traders trimmed risk ahead of the weekend. Volume was moderate " + "for a summer Friday.", 3), + ("Escalating AI infrastructure expenditures rattle technology sector", + "Technology shares came under pressure as the market re-examined the " + "capital intensity of the current build-out cycle. Several large " + "platform operators have guided to materially heavier spending on " + "data-center capacity, and investors are increasingly asking when " + "that spending converts into operating profit rather than deferred " + "depreciation.\n\n" + "Semiconductor and networking names traded in sympathy. Analysts " + "covering the group are split between treating the spending as a " + "durable demand signal and treating it as a margin headwind that has " + "not yet been reflected in consensus estimates.", 4), + ("Oil prices retreat from multi-month highs on potential diplomacy", + "Crude retreated after headlines pointed to renewed diplomatic " + "contact between major producers, easing the supply premium that had " + "built up over the previous three weeks. Energy equities gave back " + "part of their recent advance, though the group remains among the " + "stronger performers over a trailing three-month window.\n\n" + "Refining margins stayed healthy, which cushioned downstream names " + "relative to pure exploration and production issuers.", 3), + ("Implementation of new defensive tariff regime fuels trade worries", + "Industrial and materials companies with cross-border supply chains " + "traded lower after the details of a new tariff schedule circulated. " + "Management teams have generally signalled that the direct cost is " + "manageable, but the second-order effect on order timing is harder " + "to forecast.\n\n" + "Freight and logistics operators were mixed, reflecting the split " + "between volume risk and pricing power.", 5), + ], + "europe": [ + ("European benchmarks close higher as rate expectations soften", + "Continental indexes advanced, led by exporters and financials, " + "after softer inflation prints reduced the odds of another policy " + "tightening this year. Luxury and autos both participated, though " + "gains faded into the close.\n\n" + "Peripheral markets outperformed core ones, a pattern that usually " + "accompanies a narrowing of sovereign spreads.", 3), + ("Bank shares lead the advance on improved net interest guidance", + "Lenders across the region moved higher after several updated their " + "guidance for net interest income. The read-through supported the " + "sector broadly, including names that have not yet reported.", 2), + ], + "asia": [ + ("Asian markets end lower as export orders cool", + "Regional benchmarks finished in the red, with technology hardware " + "and shipping among the weakest groups. Softer export orders and a " + "firmer dollar weighed on sentiment through the session.\n\n" + "Domestic-demand names held up better than exporters.", 3), + ("Mainland indexes slip despite supportive policy signals", + "Onshore benchmarks drifted lower even after officials reiterated " + "support for the property and consumption channels. Investors are " + "waiting for measurable follow-through before re-rating the market.", 2), + ], + "latam": [ + ("Latin American equities track commodity weakness", + "Regional benchmarks followed industrial metals and crude lower. " + "Currency moves amplified the decline for dollar-based investors, " + "though local-currency returns were closer to flat.", 2), + ], + "currencies": [ + ("Dollar firms broadly as rate differentials widen", + "The dollar gained against most majors as the front end of the US " + "curve repriced. The move was orderly, with realised volatility " + "staying below its one-year average.\n\n" + "Commodity currencies underperformed, tracking softer crude.", 3), + ], + "crypto": [ + ("Digital assets consolidate after a strong month", + "Major tokens traded in a narrow band as the market digested the " + "prior month's advance. Spot volumes cooled from their recent peak " + "while derivatives funding normalised.", 2), + ], + "futures": [ + ("Metals steady while energy contracts give back gains", + "Precious metals held their range as real yields stalled, while " + "energy contracts retreated on supply headlines. Agricultural " + "contracts were mixed on weather forecasts.", 2), + ], +} + +# -------------------------------------------------------------------------- +# Market-wide "Latest updates" feed +# -------------------------------------------------------------------------- + +MARKET_HEADLINES = [ + "Investors weigh a heavy week of earnings against a firmer rates backdrop", + "Breadth stays narrow as megacaps drive another index-level move", + "Fund flows rotate toward defensives for a third consecutive week", + "Treasury curve steepens after the latest auction clears", + "Options positioning points to a quieter August, strategists say", + "Buyback authorisations run ahead of last year's pace", + "Small caps lag as financing conditions stay tight", + "Dividend growers outperform in a choppy tape", + "Analysts trim second-half estimates across cyclicals", + "Volatility sellers return as realised vol drops", + "Commodity exporters diverge from importers on tariff news", + "IPO window reopens with two large listings priced", + "Credit spreads hold near their tightest level of the year", + "Retail participation cools from its spring peak", + "Passive flows continue to dominate active in equity funds", + "Sector dispersion hits its widest reading since March", + "Corporate guidance language turns more cautious on demand", + "Freight rates stabilise after a two-month slide", + "Housing-linked equities rally on a softer mortgage print", + "Currency hedging costs fall for overseas investors", +] + +MARKET_BODY_PARAS = [ + "The move played out across most of the session rather than in a single " + "burst, which market participants generally read as repositioning rather " + "than a reaction to a discrete headline.", + "Desk commentary pointed to steady two-way flow. Several strategists " + "noted that the tape has been driven more by positioning than by any " + "change in the underlying macro picture.", + "Options markets showed no unusual demand for downside protection, which " + "argues against a stress interpretation of the day's price action.", + "Attention now turns to next week's calendar, where a cluster of " + "large-cap reports is expected to set the tone for the rest of the month.", + "Longer-horizon investors have largely stayed the course, with allocation " + "surveys showing little change in equity weightings over the quarter.", +] + +# -------------------------------------------------------------------------- +# Company news +# -------------------------------------------------------------------------- + +COMPANY_THEMES = [ + ("{name} expands its partner programme into two new regions", + "{name} said it is widening a partner programme to cover two additional " + "regions, a move management framed as a way to reach customers it has " + "historically served indirectly. The company did not attach a revenue " + "target to the expansion.", + "Competitors have taken broadly similar steps over the past year, so " + "analysts covering the group treated the announcement as a matter of " + "keeping pace rather than a differentiator."), + ("{name} names a new operating chief as it reorganises its business units", + "{name} announced an internal appointment to lead operations, alongside " + "a reshuffle that consolidates several reporting lines. The company " + "described the change as a simplification rather than a strategy shift.", + "Reorganisations of this kind usually take two to three quarters to show " + "up in reported segment results, which limits what can be read into the " + "announcement in the near term."), + ("Analysts revisit their {name} models ahead of the next reporting period", + "Several sell-side analysts published updated notes on {name} this week " + "in the run-up to its next report. The distribution of views widened " + "modestly, with the range of published price targets now broader than it " + "was at the start of the quarter.", + "Investors looking for the current consensus and the full target range " + "can find the coverage detail on the company's analysis page."), + ("{name} outlines a multi-year efficiency plan at its investor day", + "At its investor day, {name} laid out a multi-year efficiency programme " + "covering procurement, real estate and internal tooling. Management " + "declined to quantify the savings beyond describing them as material to " + "the operating line over the plan horizon.", + "The plan does not change the company's stated capital allocation " + "priorities, which management reiterated in the same session."), + ("Regulators open a routine review touching {name}'s core market", + "A sector regulator opened a routine review of competitive conditions in " + "one of {name}'s core markets. The company said it is cooperating and " + "does not expect the review to affect current operations.", + "Reviews of this type typically run for several quarters before any " + "findings are published."), + ("{name} completes a bolt-on acquisition in an adjacent category", + "{name} closed a small acquisition in an adjacent product category. " + "Terms were not disclosed, and the company said the transaction is not " + "material to its financial statements.", + "The target's technology is expected to be folded into an existing " + "product line rather than sold separately."), + ("Supply chain normalises for {name} after two constrained quarters", + "{name} told suppliers that lead times have returned to their " + "pre-disruption range after two constrained quarters. The company had " + "flagged the constraint as a limiting factor on fulfilment earlier in " + "the year.", + "Normalisation removes one of the operational headwinds management had " + "cited, though it does not by itself change demand."), + ("{name} refreshes its flagship product line for the coming cycle", + "{name} introduced a refresh of its flagship line, with changes " + "concentrated in performance and serviceability rather than pricing. " + "The company kept its existing tier structure intact.", + "Channel checks suggest the refresh has been received in line with " + "previous cycles."), + ("Institutional ownership in {name} shifts modestly last quarter", + "Quarterly filings showed a modest rotation in {name}'s institutional " + "register, with index-tracking holders roughly stable and a handful of " + "active managers adjusting position sizes.", + "Changes of this magnitude are common and rarely signal a change in the " + "fundamental outlook."), + ("{name} extends its buyback authorisation without changing the pace", + "The board of {name} extended the company's existing repurchase " + "authorisation. Management said the extension does not imply a change " + "in the pace of execution.", + "The company continues to fund repurchases from operating cash flow."), +] + + +def build_market_news(rng_key="market-news", count=20): + """[(slug, headline, body, published_at, region)] for the market feed.""" + rng = rng_for(rng_key) + out = [] + for i, headline in enumerate(MARKET_HEADLINES[:count]): + minutes = int(20 * (i ** 1.55)) + rng.randint(3, 40) + published = _stamp(minutes) + paras = rng.sample(MARKET_BODY_PARAS, 3) + body = "\n\n".join(paras) + out.append((_slugify(headline), headline, body, published, "us")) + return out + + +def build_company_news(ticker, name, rng_key=None, count=None): + """[(slug, headline, summary, body, published_at)] for one instrument.""" + rng = rng_for(rng_key or f"news-{ticker}") + n = count or rng.randint(3, 6) + picks = rng.sample(COMPANY_THEMES, n) + out = [] + for i, (h, p1, p2) in enumerate(picks): + headline = h.format(name=name) + minutes = int(90 * ((i + 1) ** 1.7)) + rng.randint(5, 300) + published = _stamp(minutes) + summary = p1.format(name=name) + body = summary + "\n\n" + p2.format(name=name) + "\n\n" + \ + rng.choice(MARKET_BODY_PARAS) + out.append((_slugify(f"{ticker}-{headline}"), headline, summary, + body, published)) + return out + + +def _stamp(minutes_ago): + base = MARKET_DATE + dt = (base.toordinal(), 20 * 60) # 20:00 on the frozen date + total = dt[1] - minutes_ago + day_offset = 0 + while total < 0: + total += 24 * 60 + day_offset += 1 + from datetime import datetime + d = base - timedelta(days=day_offset) + return datetime(d.year, d.month, d.day, total // 60, total % 60) + + +def _slugify(s): + keep = [] + for ch in s.lower(): + if ch.isalnum(): + keep.append(ch) + elif ch in " -_/": + keep.append("-") + slug = "".join(keep) + while "--" in slug: + slug = slug.replace("--", "-") + return slug.strip("-")[:120] diff --git a/sites/google_finance/_health.py b/sites/google_finance/_health.py new file mode 100644 index 00000000..e78d14a3 --- /dev/null +++ b/sites/google_finance/_health.py @@ -0,0 +1,10 @@ +"""Per-site health probe (called by control_server).""" + + +def health(): + from app import Instrument, NewsArticle, app + with app.app_context(): + return {'ok': Instrument.query.count() > 0, + 'site': 'google_finance', + 'instruments': Instrument.query.count(), + 'news': NewsArticle.query.count()} diff --git a/sites/google_finance/_market_data.py b/sites/google_finance/_market_data.py new file mode 100644 index 00000000..837e6512 --- /dev/null +++ b/sites/google_finance/_market_data.py @@ -0,0 +1,726 @@ +"""Deterministic synthetic market data for the Google Finance mirror. + +Every number the mirror shows — prices, OHLC series, key stats, financial +statements, earnings rows, analyst targets — is generated here from a fixed +seed. Nothing depends on the wall clock, so the seed DB is reproducible and +task answers are stable across runs. + +Why synthetic rather than scraped: real quotes are stale the moment they are +captured, and a frontier model can approximately recall real fundamentals, +which turns a benchmark task into a memory lookup. Frozen synthetic values +force the agent to actually read the page. + +Real-world magnitudes (an anchor price per symbol) come from the recon pass so +each instrument still lands in a plausible range. +""" +import hashlib +import math +import random +from datetime import date, datetime, time, timedelta + +# -------------------------------------------------------------------------- +# Frozen clock +# -------------------------------------------------------------------------- + +MARKET_DATE = date(2026, 7, 24) # a Friday +MARKET_CLOSE_LABEL = "Jul 24, 4:00:01 PM UTC-4" +AFTER_HOURS_LABEL = "7:59 PM" +MARKET_YEAR = MARKET_DATE.year +SESSION_OPEN = time(9, 30) +SESSION_CLOSE = time(16, 0) + +# US market holidays that fall inside the 10-year history window. Only the +# fixed-rule ones are listed; the goal is a plausible calendar, not an exact +# exchange schedule. +_FIXED_HOLIDAYS = {(1, 1), (7, 4), (12, 25), (6, 19), (11, 11)} + + +def is_trading_day(d: date) -> bool: + if d.weekday() >= 5: + return False + return (d.month, d.day) not in _FIXED_HOLIDAYS + + +def trading_days_back(end: date, n: int) -> list[date]: + """The n trading days ending on `end` (inclusive), oldest first.""" + out, d = [], end + while len(out) < n: + if is_trading_day(d): + out.append(d) + d -= timedelta(days=1) + return list(reversed(out)) + + +HISTORY_DAYS = 2520 # ~10 years of sessions +CALENDAR = trading_days_back(MARKET_DATE, HISTORY_DAYS) +CAL_INDEX = {d: i for i, d in enumerate(CALENDAR)} + +# Range key -> how many trailing sessions it spans (None = special-cased). +RANGE_SESSIONS = { + "1D": None, "5D": None, + "1M": 22, "6M": 126, "YTD": None, "1Y": 252, "5Y": 1260, "MAX": HISTORY_DAYS, +} +RANGE_KEYS = ["1D", "5D", "1M", "6M", "YTD", "1Y", "5Y", "MAX"] + + +def ytd_sessions() -> int: + jan1 = date(MARKET_YEAR, 1, 1) + return sum(1 for d in CALENDAR if d >= jan1) + + +# -------------------------------------------------------------------------- +# Per-instrument RNG +# -------------------------------------------------------------------------- + +def rng_for(symbol: str, salt: str = "") -> random.Random: + """A Random seeded only by the symbol, so generation order never matters.""" + h = hashlib.sha256(f"webharbor-gfinance-v1|{symbol}|{salt}".encode()).digest() + return random.Random(int.from_bytes(h[:8], "big")) + + +# Daily volatility and annual drift by asset class. +CLASS_PARAMS = { + "stock": (0.0155, 0.070), + "etf": (0.0085, 0.060), + "index": (0.0080, 0.055), + "sector": (0.0090, 0.055), + "crypto": (0.0330, 0.150), + "currency": (0.0040, 0.005), + "future": (0.0160, 0.030), +} + +# Sector tilts: (extra annual drift, volatility multiplier). +SECTOR_TILT = { + "Technology": (0.035, 1.20), + "Communication Services": (0.020, 1.10), + "Consumer Discretionary": (0.012, 1.15), + "Consumer Staples": (-0.008, 0.70), + "Financials": (0.008, 0.95), + "Health Care": (0.000, 0.85), + "Industrials": (0.006, 0.90), + "Energy": (-0.008, 1.15), + "Utilities": (-0.012, 0.65), + "Real Estate": (-0.012, 0.90), + "Materials": (0.000, 0.95), + "ETF": (0.008, 0.85), +} + + +def _walk(rng: random.Random, n: int, sigma: float, mu_annual: float) -> list[float]: + """A geometric random walk of n multiplicative steps, starting at 1.0. + + A slow sine cycle is layered on top so the long-range charts show market + regimes rather than pure noise. + """ + mu_daily = mu_annual / 252.0 + path, level = [1.0], 1.0 + cycle_len = rng.uniform(180, 420) + # The cycle shifts the *daily drift*, so its amplitude has to stay on the + # order of mu_daily. Scaling it to sigma instead compounds into 3x swings + # over half a cycle and produces absurd 52-week ranges. + cycle_amp = abs(mu_daily) * rng.uniform(1.2, 3.0) + sigma * 0.02 + phase = rng.uniform(0, 2 * math.pi) + for i in range(1, n): + seasonal = cycle_amp * math.sin(2 * math.pi * i / cycle_len + phase) + step = rng.gauss(mu_daily + seasonal, sigma) + step = max(-0.14, min(0.14, step)) # clip fat tails + level *= (1.0 + step) + path.append(level) + return path + + +def digits_for(kind: str, price: float) -> int: + """Quote precision. FX is always shown to four places on Google Finance; + rounding a 1.08 pair to two places collapses a whole day's move to 0.00%.""" + if kind == "currency": + return 4 + return _price_digits(price) + + +def daily_closes(symbol: str, anchor: float, kind: str, sector: str | None = None, + n: int = HISTORY_DAYS) -> list[float]: + """A `n`-session close series ending exactly at `anchor`.""" + rng = rng_for(symbol, "daily") + sigma, mu = CLASS_PARAMS.get(kind, CLASS_PARAMS["stock"]) + if sector: + d_mu, m_sigma = SECTOR_TILT.get(sector, (0.0, 1.0)) + mu += d_mu + sigma *= m_sigma + path = _walk(rng, n, sigma, mu) + scale = anchor / path[-1] + digits = digits_for(kind, anchor) + return [round(p * scale, digits) for p in path] + + +def _price_digits(price: float) -> int: + if price >= 1000: + return 2 + if price >= 1: + return 2 + if price >= 0.01: + return 4 + return 6 + + +def daily_volumes(symbol: str, base: float, n: int = HISTORY_DAYS) -> list[int]: + rng = rng_for(symbol, "vol") + out = [] + for _ in range(n): + out.append(int(base * math.exp(rng.gauss(0, 0.34)))) + return out + + +def intraday_path(symbol: str, prev_close: float, close: float, + points: int, sigma: float, salt: str = "1d", + kind: str = "stock") -> list[float]: + """An intraday path from a gapped open to exactly `close`.""" + rng = rng_for(symbol, salt) + gap = rng.gauss(0, sigma * 0.35) + start = prev_close * (1 + gap) + path = [1.0] + for _ in range(points - 1): + path.append(path[-1] * (1 + rng.gauss(0, sigma / math.sqrt(points) * 1.6))) + # pin both ends: rotate the path so it starts at `start` and ends at `close` + lo, hi = path[0], path[-1] + digits = digits_for(kind, close) + out = [] + for i, p in enumerate(path): + w = i / max(1, points - 1) + base = start + (close - start) * w + wobble = (p / (lo + (hi - lo) * w)) - 1.0 + out.append(round(base * (1 + wobble), digits)) + out[0] = round(start, digits) + out[-1] = round(close, digits) + return out + + +AFTER_HOURS_POINTS = 49 # 16:00 -> 20:00 inclusive, 5-minute steps + + +def after_hours_path(symbol: str, close: float, after_close: float, + points: int = AFTER_HOURS_POINTS, + kind: str = "stock") -> list[float]: + """The post-close leg Google draws in grey after the session divider.""" + rng = rng_for(symbol, "afterhours") + digits = digits_for(kind, close) + out, level = [], close + for i in range(points): + w = (i + 1) / points + target = close + (after_close - close) * w + level = target * (1 + rng.gauss(0, 0.0006)) + out.append(round(level, digits)) + out[-1] = round(after_close, digits) + return out + + +def intraday_volumes(symbol: str, day_volume: int, points: int) -> list[int]: + """Per-bar volume with the U-shaped open/close bulge real sessions show.""" + rng = rng_for(symbol, "intravol") + weights = [] + for i in range(points): + w = i / max(1, points - 1) + # heavy at the open and into the close, quiet mid-session + shape = 1.0 + 2.2 * math.exp(-w * 9) + 1.6 * math.exp(-(1 - w) * 7) + weights.append(shape * math.exp(rng.gauss(0, 0.30))) + total = sum(weights) or 1.0 + return [max(1, int(day_volume * w / total)) for w in weights] + + +def clock_labels(points: int, start_min: int, end_min: int) -> list[str]: + """12-hour clock labels spread across a minute range.""" + out = [] + for i in range(points): + m = start_min + round((end_min - start_min) * i / max(1, points - 1)) + hour24, minute = m // 60, m % 60 + suffix = "AM" if hour24 < 12 else "PM" + hour12 = hour24 % 12 or 12 + out.append(f"{hour12}:{minute:02d} {suffix}") + return out + + +def session_times(points: int) -> list[str]: + """Clock labels across one 6.5-hour regular session.""" + return clock_labels(points, + SESSION_OPEN.hour * 60 + SESSION_OPEN.minute, + SESSION_CLOSE.hour * 60 + SESSION_CLOSE.minute) + + +def after_hours_times(points: int) -> list[str]: + """Clock labels from the closing bell to the end of after-hours.""" + return clock_labels(points, SESSION_CLOSE.hour * 60, 20 * 60) + + +# -------------------------------------------------------------------------- +# Formatting helpers (match Google Finance's own abbreviations) +# -------------------------------------------------------------------------- + +def fmt_big(n: float | int | None) -> str: + if n is None: + return "—" + n = float(n) + for div, suf in ((1e12, "T"), (1e9, "B"), (1e6, "M"), (1e3, "K")): + if abs(n) >= div: + return f"{n / div:.2f}{suf}" + return f"{n:,.0f}" + + +def fmt_price(p: float | None, currency: str = "USD") -> str: + if p is None: + return "—" + sym = {"USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥"}.get(currency, "") + d = _price_digits(p) + return f"{sym}{p:,.{d}f}" + + +def fmt_pct(p: float | None) -> str: + if p is None: + return "—" + return f"{p:+.2f}%" + + +def fmt_money_signed(n: float | None, currency: str = "USD") -> str: + """Signed amount with the sign ahead of the currency symbol: -$2,233.00. + + '${:+,.2f}'.format() would put it after the symbol ($-2,233.00), which no + finance UI does. + """ + if n is None: + return "—" + sym = {"USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥"}.get(currency, "") + return f"{'-' if n < 0 else '+'}{sym}{abs(n):,.2f}" + + +# -------------------------------------------------------------------------- +# Derived key statistics +# -------------------------------------------------------------------------- + +# Sectors where a dividend is the norm rather than the exception. +INCOME_SECTORS = {"Utilities", "Consumer Staples", "Energy", "Real Estate", + "Materials", "Financials", "Health Care", "Industrials"} + +# Sector-typical fundamentals: (price/sales, net margin, dividend yield %, +# beta centre, P/E centre) +SECTOR_FUNDAMENTALS = { + "Technology": (8.5, 0.24, 0.5, 1.15, 34), + "Communication Services": (5.0, 0.20, 0.7, 1.10, 24), + "Consumer Discretionary": (3.2, 0.11, 0.8, 1.20, 28), + "Consumer Staples": (2.4, 0.10, 2.5, 0.60, 22), + "Financials": (3.6, 0.26, 2.2, 1.00, 15), + "Health Care": (4.4, 0.17, 1.6, 0.75, 21), + "Industrials": (2.8, 0.12, 1.5, 1.00, 23), + "Energy": (1.5, 0.10, 3.4, 1.05, 13), + "Utilities": (2.6, 0.13, 3.2, 0.55, 18), + "Real Estate": (7.0, 0.28, 3.6, 0.90, 33), + "Materials": (2.2, 0.11, 2.0, 1.05, 19), + "ETF": (0, 0, 1.4, 1.00, 0), +} + + +def equity_stats(symbol: str, sector: str, closes: list[float], + volumes: list[int], intraday: list[float], + anchors: dict | None = None) -> dict: + """Key stats derived from the generated series — one source of truth. + + Everything downstream (market cap, P/E, EPS, the income statement) is + computed from these same numbers, so no two fields on the page can + disagree. That is hardening dimension D, enforced structurally. + """ + rng = rng_for(symbol, "stats") + ps, margin, div_y, beta_c, pe_c = SECTOR_FUNDAMENTALS.get( + sector, SECTOR_FUNDAMENTALS["Technology"]) + + price = closes[-1] + prev_close = closes[-2] + change = round(price - prev_close, _price_digits(price)) + change_pct = round(change / prev_close * 100, 2) + + day_open = intraday[0] + day_high = max(intraday) + day_low = min(intraday) + + # The 52-week band has to contain the current session, otherwise the page + # shows a day low beneath the 52-week low. + last_year = closes[-252:] + wk52_high = max(max(last_year), day_high) + wk52_low = min(min(last_year), day_low) + + # Market cap is the primary size knob; share count and traded volume are + # derived from it. When the recon pass captured a real cap for this symbol + # it is used as the magnitude anchor and perturbed, so a mega-cap name does + # not end up with a mid-cap balance sheet. Deriving cap from volume instead + # lands multi-trillion caps on ordinary names. + anchors = anchors or {} + if anchors.get("mkt_cap"): + mkt_cap = anchors["mkt_cap"] * rng.uniform(0.86, 1.16) + else: + mkt_cap = math.exp(rng.uniform(math.log(1.2e10), math.log(3.4e12))) + shares = int(mkt_cap / price) + if anchors.get("avg_volume"): + avg_volume = int(anchors["avg_volume"] * rng.uniform(0.82, 1.24)) + else: + avg_volume = int(shares * rng.uniform(0.0022, 0.011)) + volume = int(avg_volume * (volumes[-1] / (sum(volumes[-30:]) / 30))) + + if sector == "ETF": + eps = pe_ratio = None + revenue_ttm = net_income_ttm = None + else: + revenue_ttm = mkt_cap / (ps * rng.uniform(0.75, 1.3)) + net_income_ttm = revenue_ttm * margin * rng.uniform(0.7, 1.3) + eps = round(net_income_ttm / shares, 2) + pe_ratio = round(price / eps, 2) if eps and eps > 0 else None + + # Who pays a dividend: income sectors essentially all do, and so do the + # very largest names elsewhere. A flat coin flip left mega-caps like AAPL + # showing no dividend at all, which reads as broken data rather than as a + # non-payer. + if sector == "ETF": + pays_dividend = False + elif sector in INCOME_SECTORS: + pays_dividend = True + else: + pays_dividend = mkt_cap >= 8e11 or rng.random() < 0.35 + if pays_dividend: + dividend_yield = round(max(0.05, rng.gauss(div_y, div_y * 0.35)), 2) + quarterly_dividend = round(price * dividend_yield / 100 / 4, 2) + ex_div = MARKET_DATE + timedelta(days=rng.randint(9, 80)) + ex_div_label = ex_div.strftime("%b %-d, %Y") + else: + dividend_yield = quarterly_dividend = None + ex_div_label = None + + after_change_pct = round(rng.gauss(0, 0.28), 2) + after_price = round(price * (1 + after_change_pct / 100), _price_digits(price)) + + return { + "price": price, "prev_close": prev_close, + "change": change, "change_pct": change_pct, + "day_open": day_open, "day_high": day_high, "day_low": day_low, + "wk52_high": wk52_high, "wk52_low": wk52_low, + "volume": volume, "avg_volume": avg_volume, + "shares_outstanding": shares, "mkt_cap": mkt_cap, + "eps": eps, "pe_ratio": pe_ratio, + "beta": round(max(0.15, rng.gauss(beta_c, 0.22)), 2), + "dividend_yield": dividend_yield, + "quarterly_dividend": quarterly_dividend, + "ex_dividend_date": ex_div_label, + "after_price": after_price, + "after_change": round(after_price - price, _price_digits(price)), + "after_change_pct": after_change_pct, + "revenue_ttm": revenue_ttm, "net_income_ttm": net_income_ttm, + } + + +def simple_stats(symbol: str, closes: list[float], intraday: list[float], + kind: str = "index") -> dict: + """Reduced stat set used by indexes, currencies, crypto and futures.""" + price = closes[-1] + prev_close = closes[-2] + change = round(price - prev_close, digits_for(kind, price)) + last_year = closes[-252:] + day_high, day_low = max(intraday), min(intraday) + return { + "price": price, "prev_close": prev_close, "change": change, + "change_pct": round(change / prev_close * 100, 2), + "day_open": intraday[0], "day_high": day_high, "day_low": day_low, + "wk52_high": max(max(last_year), day_high), + "wk52_low": min(min(last_year), day_low), + } + + +# -------------------------------------------------------------------------- +# Financial statements, earnings, analyst coverage +# -------------------------------------------------------------------------- + +QUARTER_ENDS = ["Mar", "Jun", "Sep", "Dec"] + + +def recent_quarters(n: int = 8) -> list[str]: + """Quarter labels ending with the last completed quarter before MARKET_DATE.""" + q = (MARKET_DATE.month - 1) // 3 # 0-based quarter of MARKET_DATE + y = MARKET_YEAR + q -= 1 # last *completed* quarter + if q < 0: + q, y = 3, y - 1 + out = [] + for _ in range(n): + out.append(f"{QUARTER_ENDS[q]} {y}") + q -= 1 + if q < 0: + q, y = 3, y - 1 + return list(reversed(out)) + + +def income_statement(symbol: str, revenue_ttm: float, net_income_ttm: float, + shares: int, periods: list[str]) -> list[dict]: + """Quarterly income statement rows that sum to the TTM figures.""" + rng = rng_for(symbol, "income") + rows = [] + # quarterly seasonality: Dec quarter is the strongest for most issuers + season = {"Mar": 0.92, "Jun": 0.95, "Sep": 1.00, "Dec": 1.13} + n = len(periods) + for i, label in enumerate(periods): + # older quarters are smaller, so the series trends up + growth = (1 + rng.uniform(0.015, 0.055)) ** (i - (n - 4)) + s = season[label.split()[0]] + rev = revenue_ttm / 4 * growth * s * rng.uniform(0.97, 1.03) + margin = (net_income_ttm / revenue_ttm) * rng.uniform(0.85, 1.15) + ni = rev * margin + gross = rev * rng.uniform(0.38, 0.62) + opinc = rev * margin * rng.uniform(1.15, 1.45) + rows.append({ + "period": label, + "revenue": rev, + "cost_of_revenue": rev - gross, + "gross_profit": gross, + "operating_expense": max(0.0, gross - opinc), + "operating_income": opinc, + "net_income": ni, + "net_profit_margin": margin * 100, + "eps": ni / shares, + "ebitda": opinc * rng.uniform(1.12, 1.35), + "effective_tax_rate": rng.uniform(11.0, 23.5), + }) + return rows + + +def balance_sheet(symbol: str, revenue_ttm: float, periods: list[str]) -> list[dict]: + """Quarterly balance sheet that drifts instead of jumping. + + Drawing each ratio independently per quarter made total assets swing by + 2x between consecutive periods, which no real balance sheet does. The + company-level ratios are drawn once and then nudged a few percent a + quarter. + """ + rng = rng_for(symbol, "balance") + asset_turns = rng.uniform(0.9, 2.4) # assets / annual revenue + leverage = rng.uniform(0.45, 0.82) # liabilities / assets + cash_share = rng.uniform(0.08, 0.26) + pb = rng.uniform(2.0, 12.0) + roa = rng.uniform(3.0, 18.0) + roc = roa * rng.uniform(1.3, 2.1) + + rows = [] + for i, label in enumerate(periods): + growth = (1 + rng.uniform(0.008, 0.03)) ** (i - len(periods) + 1) + assets = revenue_ttm * asset_turns * growth * rng.uniform(0.985, 1.015) + lev = min(0.9, max(0.2, leverage * rng.uniform(0.98, 1.02))) + liab = assets * lev + rows.append({ + "period": label, + "cash_and_short_term_investments": + assets * cash_share * rng.uniform(0.93, 1.07), + "total_assets": assets, + "total_liabilities": liab, + "total_equity": assets - liab, + "price_to_book": pb * rng.uniform(0.95, 1.05), + "return_on_assets": roa * rng.uniform(0.92, 1.08), + "return_on_capital": roc * rng.uniform(0.92, 1.08), + }) + return rows + + +def cash_flow(symbol: str, net_income_ttm: float, periods: list[str]) -> list[dict]: + rng = rng_for(symbol, "cashflow") + # Conversion and reinvestment rates are company traits, not quarterly coin + # flips; only the quarter-to-quarter noise is redrawn. + op_conversion = rng.uniform(1.1, 1.7) + invest_rate = rng.uniform(0.2, 0.7) + finance_rate = rng.uniform(0.15, 0.65) + rows = [] + for i, label in enumerate(periods): + ni = net_income_ttm / 4 * (1 + 0.02 * i) * rng.uniform(0.9, 1.1) + op = ni * op_conversion * rng.uniform(0.95, 1.05) + inv = -op * invest_rate * rng.uniform(0.9, 1.1) + fin = -op * finance_rate * rng.uniform(0.9, 1.1) + rows.append({ + "period": label, + "net_income": ni, + "cash_from_operations": op, + "cash_from_investing": inv, + "cash_from_financing": fin, + "net_change_in_cash": op + inv + fin, + "free_cash_flow": op * rng.uniform(0.55, 0.9), + }) + return rows + + +def earnings_rows(symbol: str, income_rows: list[dict]) -> list[dict]: + """Reported vs. consensus for the last 8 quarters.""" + rng = rng_for(symbol, "earnings") + out = [] + for r in income_rows: + eps_actual = r["eps"] + surprise = rng.gauss(0.03, 0.06) + eps_est = eps_actual / (1 + surprise) if (1 + surprise) else eps_actual + rev_surprise = rng.gauss(0.01, 0.03) + rev_est = r["revenue"] / (1 + rev_surprise) + # report date: ~4 weeks after quarter end + mon, yr = r["period"].split() + qend_month = {"Mar": 3, "Jun": 6, "Sep": 9, "Dec": 12}[mon] + d = date(int(yr), qend_month, 28) + timedelta(days=rng.randint(20, 40)) + out.append({ + "quarter": r["period"], + "report_date": d.isoformat(), + "eps_estimate": round(eps_est, 2), + "eps_actual": round(eps_actual, 2), + "revenue_estimate": rev_est, + "revenue_actual": r["revenue"], + "surprise_pct": round((eps_actual - eps_est) / abs(eps_est) * 100, 2) + if eps_est else 0.0, + }) + return out + + +# -------------------------------------------------------------------------- +# Key moments — the orange markers Google drops on the long-range charts +# -------------------------------------------------------------------------- + +MOVE_HEADLINES = [ + "Shares {dir} {pct:.1f}% in a single session", + "{dir_cap} {pct:.1f}% as the sector repriced", + "Biggest one-day {word} in months: {pct:.1f}%", + "Stock {dir} {pct:.1f}% on heavy volume", +] + + +def key_moments(symbol: str, closes: list[float], calendar: list, + earnings: list[dict] | None = None, max_moves: int = 6) -> list[dict]: + """Notable dates for one instrument: earnings prints and outsized moves. + + Google marks these on 6M and longer with an orange halo. Deriving them from + the same series the chart draws keeps the marker on a real inflection + instead of an arbitrary date. + """ + out = [] + for e in (earnings or []): + d = e["report_date"] + verdict = ("beat" if e["surprise_pct"] > 1 else + "missed" if e["surprise_pct"] < -1 else "met") + out.append({ + "date": d, + "kind": "earnings", + "title": f"{e['quarter']} earnings — EPS {verdict} estimates", + }) + + # Outsized single-session moves. Picked per horizon rather than globally: + # taking the top N across five years leaves a 1Y chart with no markers at + # all, because the biggest moves cluster in whichever year was wildest. + chosen = [] + for horizon, want in ((126, 3), (252, 3), (1260, 4)): + window = min(len(closes), horizon) + moves = sorted( + ((abs(closes[i] / closes[i - 1] - 1), i) + for i in range(len(closes) - window + 1, len(closes)) + if closes[i - 1]), reverse=True) + taken = 0 + for _, i in moves: + if taken >= want or len(chosen) >= max_moves + 4: + break + if all(abs(i - j) > 25 for j in chosen): + chosen.append(i) + taken += 1 + for i in sorted(chosen): + pct = (closes[i] / closes[i - 1] - 1) * 100 + up = pct >= 0 + tmpl = MOVE_HEADLINES[i % len(MOVE_HEADLINES)] + out.append({ + "date": calendar[i].isoformat(), + "kind": "move", + "title": tmpl.format(dir="rose" if up else "fell", + dir_cap="Climbed" if up else "Slid", + word="gain" if up else "drop", + pct=abs(pct)), + }) + out.sort(key=lambda m: m["date"]) + return out + + +# Institutional holders shown on a stock's Holdings tab. Real asset-manager +# names; the position sizes are synthetic like every other figure here. +HOLDER_FIRMS = [ + "Vanguard Group Inc", "BlackRock Inc", "State Street Corp", + "Fidelity Management & Research", "Geode Capital Management", + "T. Rowe Price Associates", "Capital Research Global Investors", + "Northern Trust Corp", "Morgan Stanley Investment Management", + "Invesco Ltd", "Legal & General Group", "Bank of America Corp", + "Charles Schwab Investment Management", "Wellington Management", + "Norges Bank Investment Management", +] + + +def institutional_holders(symbol: str, shares_outstanding: int, + price: float) -> list[dict]: + """Top holders, ordered by stake. Weights fall off geometrically so the + register looks like a real one: two or three index giants, then a tail.""" + rng = rng_for(symbol, "holders") + n = rng.randint(8, 12) + firms = rng.sample(HOLDER_FIRMS, n) + firms.sort(key=lambda f: HOLDER_FIRMS.index(f)) # index funds on top + top = rng.uniform(7.0, 9.5) + decay = rng.uniform(0.72, 0.86) + out, pct = [], top + for f in firms: + pct_here = max(0.15, pct * rng.uniform(0.9, 1.1)) + shares = int(shares_outstanding * pct_here / 100) + out.append({ + "firm": f, + "pct_held": round(pct_here, 2), + "shares": shares, + "value": shares * price, + "change_pct": round(rng.gauss(0, 4.5), 2), # quarter-on-quarter + }) + pct *= decay + return out + + +ANALYST_FIRMS = [ + "Morgan Stanley", "Goldman Sachs", "JPMorgan", "Bank of America", + "Citigroup", "Wells Fargo", "Barclays", "UBS", "Jefferies", + "Deutsche Bank", "RBC Capital", "Evercore ISI", "Bernstein", + "Piper Sandler", "Truist Securities", "Baird", "Stifel", "Wedbush", + "Raymond James", "Oppenheimer", +] +RATINGS = ["Strong buy", "Buy", "Hold", "Sell", "Strong sell"] + + +def analyst_coverage(symbol: str, price: float) -> list[dict]: + rng = rng_for(symbol, "analysts") + n = rng.randint(8, 16) + firms = rng.sample(ANALYST_FIRMS, n) + tilt = rng.uniform(-0.6, 1.1) # per-name sentiment + out = [] + for f in firms: + z = rng.gauss(tilt, 0.9) + rating = ("Strong buy" if z > 1.2 else "Buy" if z > 0.25 else + "Hold" if z > -0.8 else "Sell" if z > -1.6 else "Strong sell") + upside = {"Strong buy": 0.24, "Buy": 0.13, "Hold": 0.02, + "Sell": -0.10, "Strong sell": -0.20}[rating] + target = price * (1 + upside + rng.gauss(0, 0.05)) + d = MARKET_DATE - timedelta(days=rng.randint(3, 120)) + out.append({ + "firm": f, "rating": rating, + "price_target": round(target, 2), + "rated_on": d.isoformat(), + }) + out.sort(key=lambda r: r["rated_on"], reverse=True) + return out + + +def rating_consensus(ratings: list[dict]) -> dict: + counts = {r: 0 for r in RATINGS} + for r in ratings: + counts[r["rating"]] += 1 + score_map = {"Strong buy": 5, "Buy": 4, "Hold": 3, "Sell": 2, "Strong sell": 1} + total = len(ratings) or 1 + score = sum(score_map[r["rating"]] for r in ratings) / total + label = ("Strong buy" if score >= 4.5 else "Buy" if score >= 3.5 else + "Hold" if score >= 2.5 else "Sell" if score >= 1.5 else "Strong sell") + targets = sorted(r["price_target"] for r in ratings) + return { + "counts": counts, "total": total, + "score": round(score, 2), "label": label, + "target_low": targets[0] if targets else None, + "target_high": targets[-1] if targets else None, + "target_mean": round(sum(targets) / len(targets), 2) if targets else None, + } diff --git a/sites/google_finance/_universe.py b/sites/google_finance/_universe.py new file mode 100644 index 00000000..6c04fcae --- /dev/null +++ b/sites/google_finance/_universe.py @@ -0,0 +1,193 @@ +"""The instrument universe for the google_finance mirror. + +Identities only (ticker / exchange / class). Company metadata is harvested +from the live site by harvest_assets.py; all market data is synthesized by +the seed generator. +""" + +# --- US equities, grouped by GICS sector ----------------------------------- +STOCKS = { + "Technology": [ + ("AAPL", "NASDAQ"), ("MSFT", "NASDAQ"), ("NVDA", "NASDAQ"), + ("AVGO", "NASDAQ"), ("ORCL", "NYSE"), ("CRM", "NYSE"), + ("AMD", "NASDAQ"), ("ADBE", "NASDAQ"), ("CSCO", "NASDAQ"), + ("ACN", "NYSE"), ("INTC", "NASDAQ"), ("QCOM", "NASDAQ"), + ("TXN", "NASDAQ"), ("IBM", "NYSE"), ("NOW", "NYSE"), + ("INTU", "NASDAQ"), ("AMAT", "NASDAQ"), ("MU", "NASDAQ"), + ("PANW", "NASDAQ"), ("LRCX", "NASDAQ"), + ], + "Communication Services": [ + ("GOOGL", "NASDAQ"), ("META", "NASDAQ"), ("NFLX", "NASDAQ"), + ("DIS", "NYSE"), ("CMCSA", "NASDAQ"), ("T", "NYSE"), + ("VZ", "NYSE"), ("TMUS", "NASDAQ"), ("EA", "NASDAQ"), + ("WBD", "NASDAQ"), + ], + "Consumer Discretionary": [ + ("AMZN", "NASDAQ"), ("TSLA", "NASDAQ"), ("HD", "NYSE"), + ("MCD", "NYSE"), ("NKE", "NYSE"), ("SBUX", "NASDAQ"), + ("LOW", "NYSE"), ("BKNG", "NASDAQ"), ("TJX", "NYSE"), + ("GM", "NYSE"), ("F", "NYSE"), ("ABNB", "NASDAQ"), + ], + "Consumer Staples": [ + ("PG", "NYSE"), ("KO", "NYSE"), ("PEP", "NASDAQ"), + ("COST", "NASDAQ"), ("WMT", "NASDAQ"), ("PM", "NYSE"), + ("MDLZ", "NASDAQ"), ("CL", "NYSE"), ("KMB", "NASDAQ"), + ("GIS", "NYSE"), + ], + "Financials": [ + ("JPM", "NYSE"), ("BAC", "NYSE"), ("WFC", "NYSE"), + ("GS", "NYSE"), ("MS", "NYSE"), ("C", "NYSE"), + ("AXP", "NYSE"), ("BLK", "NYSE"), ("SCHW", "NYSE"), + ("SPGI", "NYSE"), ("V", "NYSE"), ("MA", "NYSE"), + ], + "Health Care": [ + ("UNH", "NYSE"), ("JNJ", "NYSE"), ("LLY", "NYSE"), + ("ABBV", "NYSE"), ("MRK", "NYSE"), ("PFE", "NYSE"), + ("TMO", "NYSE"), ("ABT", "NYSE"), ("AMGN", "NASDAQ"), + ("DHR", "NYSE"), ("GILD", "NASDAQ"), ("CVS", "NYSE"), + ], + "Industrials": [ + ("CAT", "NYSE"), ("BA", "NYSE"), ("HON", "NASDAQ"), + ("GE", "NYSE"), ("UPS", "NYSE"), ("RTX", "NYSE"), + ("LMT", "NYSE"), ("DE", "NYSE"), ("UNP", "NYSE"), + ("MMM", "NYSE"), + ], + "Energy": [ + ("XOM", "NYSE"), ("CVX", "NYSE"), ("COP", "NYSE"), + ("SLB", "NYSE"), ("EOG", "NYSE"), ("PSX", "NYSE"), + ("MPC", "NYSE"), ("OXY", "NYSE"), + ], + "Utilities": [ + ("NEE", "NYSE"), ("DUK", "NYSE"), ("SO", "NYSE"), + ("AEP", "NASDAQ"), ("D", "NYSE"), ("EXC", "NASDAQ"), + ], + "Real Estate": [ + ("AMT", "NYSE"), ("PLD", "NYSE"), ("EQIX", "NASDAQ"), + ("SPG", "NYSE"), ("O", "NYSE"), ("PSA", "NYSE"), + ], + "Materials": [ + ("LIN", "NASDAQ"), ("SHW", "NYSE"), ("APD", "NYSE"), + ("ECL", "NYSE"), ("NEM", "NYSE"), ("FCX", "NYSE"), + ("DOW", "NYSE"), + ], +} + +ETFS = [ + ("SPY", "NYSEARCA"), ("VOO", "NYSEARCA"), ("QQQ", "NASDAQ"), + ("VTI", "NYSEARCA"), ("IVV", "NYSEARCA"), ("ARKK", "BATS"), + ("XLK", "NYSEARCA"), ("XLE", "NYSEARCA"), ("XLF", "NYSEARCA"), + ("VIG", "NYSEARCA"), ("SCHD", "NYSEARCA"), ("IWM", "NYSEARCA"), +] + +# (ticker, exchange, display name, region) +INDEXES = [ + (".DJI", "INDEXDJX", "Dow Jones Industrial Average", "us"), + (".INX", "INDEXSP", "S&P 500", "us"), + (".IXIC", "INDEXNASDAQ", "Nasdaq Composite", "us"), + ("RUT", "INDEXRUSSELL", "Russell 2000 Index", "us"), + ("VIX", "INDEXCBOE", "CBOE Volatility Index", "us"), + ("DAX", "INDEXDB", "DAX Performance Index", "europe"), + ("UKX", "INDEXFTSE", "FTSE 100 Index", "europe"), + ("PX1", "INDEXEURO", "CAC 40 Index", "europe"), + ("I", "INDEXBME", "IBEX 35 Index", "europe"), + ("SX5E", "INDEXSTOXX", "STOXX Europe 50 Index", "europe"), + ("NI225", "INDEXNIKKEI", "Nikkei 225", "asia"), + ("000001", "SHA", "SSE Composite Index", "asia"), + ("HSI", "INDEXHANGSENG", "Hang Seng Index", "asia"), + ("SENSEX", "INDEXBOM", "S&P BSE SENSEX", "asia"), + ("NIFTY_50", "INDEXNSE", "NIFTY 50", "asia"), + ("SPLAC", "INDEXSP", "S&P Latin America 40", "latam"), + ("SPCBMIRLAUSD", "INDEXSP", "S&P Latin America BMI", "latam"), + ("IBOV", "INDEXBVMF", "IBOVESPA", "latam"), + ("IGCX", "INDEXBVMF", "Brazil IGC Index", "latam"), + ("IBXX", "INDEXBVMF", "Brazil IBrX 100 Index", "latam"), +] + +# Magnitude anchors for the indexes the asset harvest did not visit. The +# harvest only recorded anchors for symbols whose quote page it opened; these +# six appear on the live home page's region tabs but were never opened, so +# their levels are read off the captured home-page DOM instead +# (scraped_data/recon_summary.json). Without an entry here seed_data falls +# back to DEFAULT_LEVEL['index'] = 4200 and IBOVESPA would render two orders +# of magnitude below the real index. +INDEX_LEVELS = { + "NIFTY_50:INDEXNSE": 23767.45, + "SPLAC:INDEXSP": 3553.29, + "SPCBMIRLAUSD:INDEXSP": 309.42, + "IBOV:INDEXBVMF": 174041.95, + "IGCX:INDEXBVMF": 26932.54, + "IBXX:INDEXBVMF": 73429.35, +} + +# Left-rail CBOE equity-sector indexes: (ticker, label, matching GICS sector) +SECTOR_INDEXES = [ + ("SIXB", "Materials", "Materials"), + ("SIXC", "Communications", "Communication Services"), + ("SIXE", "Energy", "Energy"), + ("SIXI", "Industrials", "Industrials"), + ("SIXM", "Financials", "Financials"), + ("SIXR", "Staples", "Consumer Staples"), + ("SIXRE", "Real estate", "Real Estate"), + ("SIXT", "Technology", "Technology"), + ("SIXU", "Utilities", "Utilities"), + ("SIXV", "Health care", "Health Care"), + ("SIXY", "Discretionary", "Consumer Discretionary"), +] + +CRYPTO = [ + ("BTC-USD", "Bitcoin"), ("ETH-USD", "Ethereum"), ("USDT-USD", "Tether"), + ("XRP-USD", "XRP"), ("SOL-USD", "Solana"), ("BNB-USD", "BNB"), + ("DOGE-USD", "Dogecoin"), ("ADA-USD", "Cardano"), + ("AVAX-USD", "Avalanche"), ("LINK-USD", "Chainlink"), +] + +CURRENCIES = [ + ("EUR-USD", "Euro / US Dollar"), ("GBP-USD", "British Pound / US Dollar"), + ("USD-JPY", "US Dollar / Japanese Yen"), ("USD-CAD", "US Dollar / Canadian Dollar"), + ("AUD-USD", "Australian Dollar / US Dollar"), ("USD-CHF", "US Dollar / Swiss Franc"), + ("USD-CNY", "US Dollar / Chinese Yuan"), ("USD-INR", "US Dollar / Indian Rupee"), + ("USD-MXN", "US Dollar / Mexican Peso"), ("EUR-GBP", "Euro / British Pound"), +] + +FUTURES = [ + ("CL=F", "Crude Oil"), ("GC=F", "Gold"), ("SI=F", "Silver"), + ("NG=F", "Natural Gas"), ("ZC=F", "Corn"), ("ES=F", "E-Mini S&P 500"), +] + + +def all_equity_symbols(): + """[(ticker, exchange, sector)] for stocks; ETFs get sector 'ETF'.""" + out = [] + for sector, rows in STOCKS.items(): + for t, ex in rows: + out.append((t, ex, sector)) + for t, ex in ETFS: + out.append((t, ex, "ETF")) + return out + + +if __name__ == "__main__": + eq = all_equity_symbols() + print(f"stocks+etfs={len(eq)} indexes={len(INDEXES)} " + f"sector_idx={len(SECTOR_INDEXES)} crypto={len(CRYPTO)} " + f"fx={len(CURRENCIES)} futures={len(FUTURES)}") + print("TOTAL:", len(eq) + len(INDEXES) + len(SECTOR_INDEXES) + + len(CRYPTO) + len(CURRENCIES) + len(FUTURES)) + + +# Compact labels used on the home-page index cards, matching the live site +# ("Dow Jones", not "Dow Jones Industrial Average"). +SHORT_NAMES = { + ".DJI": "Dow Jones", ".INX": "S&P 500", ".IXIC": "Nasdaq", + "RUT": "Russell", "VIX": "VIX", + "DAX": "DAX", "UKX": "FTSE 100", "PX1": "CAC 40", + "I": "IBEX 35", "SX5E": "STOXX 50", + "NI225": "Nikkei 225", "000001": "SSE", "HSI": "HSI", "SENSEX": "SENSEX", + "NIFTY_50": "NIFTY 50", + "SPLAC": "S&P LATAM 40", "SPCBMIRLAUSD": "S&P LATAM BMI", + "IBOV": "IBOVESPA", "IGCX": "IGCX", "IBXX": "IBXX", + "BTC-USD": "Bitcoin", "ETH-USD": "Ethereum", "USDT-USD": "Tether", + "XRP-USD": "XRP", "SOL-USD": "Solana", "BNB-USD": "BNB", + "CL=F": "Crude Oil", "GC=F": "Gold", "SI=F": "Silver", + "NG=F": "Natural Gas", "ZC=F": "Corn", "ES=F": "E-Mini S&P 500", +} diff --git a/sites/google_finance/app.py b/sites/google_finance/app.py new file mode 100644 index 00000000..c5d4215b --- /dev/null +++ b/sites/google_finance/app.py @@ -0,0 +1,1863 @@ +#!/usr/bin/env python3 +"""Google Finance mirror — Flask application. + +Routes + SQLAlchemy models for an offline mirror of google.com/finance. +Runtime data comes entirely from instance/google_finance.db (restored from +instance_seed/google_finance.db at boot and on /reset). No request handler +reads a file from scraped_data/. + +The market clock is frozen at _market_data.MARKET_DATE so every page, every +"N hours ago" and every task answer is identical across runs. +""" +import json +import math +import os +import re +from datetime import datetime, timedelta + +from flask import (Flask, abort, flash, jsonify, redirect, render_template, + request, url_for) +from markupsafe import Markup +from flask_bcrypt import Bcrypt +from flask_login import (LoginManager, UserMixin, current_user, login_required, + login_user, logout_user) +from flask_sqlalchemy import SQLAlchemy +from flask_wtf import FlaskForm +from flask_wtf.csrf import CSRFProtect +from sqlalchemy import func, or_ +from wtforms import PasswordField, StringField +from wtforms.validators import DataRequired, Email, EqualTo, Length + +import _market_data as MD + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +app = Flask(__name__) +app.config['SECRET_KEY'] = 'google-finance-mirror-secret-key' +# Overridable so the seed-regeneration tooling can build instance_seed/ +# directly instead of the runtime instance/. +DB_PATH = (os.environ.get('GF_DB_PATH') + or os.path.join(BASE_DIR, 'instance', 'google_finance.db')) +app.config['SQLALCHEMY_DATABASE_URI'] = f"sqlite:///{DB_PATH}" +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +app.config['WTF_CSRF_TIME_LIMIT'] = None + +os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + +db = SQLAlchemy(app) +bcrypt = Bcrypt(app) +login_manager = LoginManager(app) +login_manager.login_view = 'login' +login_manager.login_message = 'Sign in to use lists and portfolios.' +login_manager.login_message_category = 'info' +csrf = CSRFProtect(app) + +STOPWORDS = {'a', 'an', 'the', 'of', 'to', 'in', 'on', 'for', 'and', 'or', + 'is', 'are', 'be', 'with', 'as', 'by', 'at', 'from', 'stock', + 'stocks', 'share', 'shares', 'price', 'quote', 'inc', 'corp'} + +REGIONS = [('us', 'US'), ('europe', 'Europe'), ('asia', 'Asia'), + ('latam', 'Latin America'), ('currencies', 'Currencies'), + ('crypto', 'Crypto'), ('futures', 'Futures')] + +MARKET_PAGES = [ + ('most-active', 'Most active'), + ('gainers', 'Gainers'), + ('losers', 'Losers'), + ('indexes', 'Market indexes'), + ('cryptocurrencies', 'Cryptocurrencies'), + ('currencies', 'Currencies'), + ('futures', 'Futures'), + ('climate-leaders', 'Climate leaders'), +] + + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- + +class User(db.Model, UserMixin): + __tablename__ = 'users' + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(120), unique=True, nullable=False, index=True) + username = db.Column(db.String(80), unique=True, nullable=False) + name = db.Column(db.String(120), nullable=False) + password_hash = db.Column(db.String(255), nullable=False) + + watchlists = db.relationship('Watchlist', backref='user', lazy=True, + cascade='all, delete-orphan') + portfolios = db.relationship('Portfolio', backref='user', lazy=True, + cascade='all, delete-orphan') + + def set_password(self, pw): + self.password_hash = bcrypt.generate_password_hash(pw).decode('utf-8') + + def check_password(self, pw): + return bcrypt.check_password_hash(self.password_hash, pw) + + +class Instrument(db.Model): + __tablename__ = 'instruments' + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(60), unique=True, nullable=False, index=True) + ticker = db.Column(db.String(24), nullable=False, index=True) + exchange = db.Column(db.String(24), default='') + name = db.Column(db.String(160), nullable=False) + short_name = db.Column(db.String(60), default='') # index-card label + # stock | etf | index | sector | crypto | currency | future + kind = db.Column(db.String(16), nullable=False, index=True) + sector = db.Column(db.String(60), default='', index=True) + industry = db.Column(db.String(120), default='') + currency = db.Column(db.String(8), default='USD') + region = db.Column(db.String(16), default='us', index=True) + logo_file = db.Column(db.String(120), default='') + profile = db.Column(db.Text, default='') + ceo = db.Column(db.String(120), default='') + headquarters = db.Column(db.String(160), default='') + founded = db.Column(db.String(60), default='') + employees = db.Column(db.String(40), default='') + website = db.Column(db.String(160), default='') + is_climate_leader = db.Column(db.Boolean, default=False, index=True) + related_json = db.Column(db.Text, default='[]') + + quote = db.relationship('Quote', backref='instrument', uselist=False, + cascade='all, delete-orphan') + history = db.relationship('PriceHistory', backref='instrument', + uselist=False, cascade='all, delete-orphan') + + @property + def related_slugs(self): + try: + return json.loads(self.related_json or '[]') + except Exception: + return [] + + @property + def card_name(self): + return self.short_name or self.name + + @property + def display_symbol(self): + return f"{self.ticker}:{self.exchange}" if self.exchange else self.ticker + + @property + def logo_url(self): + if self.logo_file: + return url_for('static', filename=f'images/{self.logo_file}') + return None + + +class Quote(db.Model): + __tablename__ = 'quotes' + id = db.Column(db.Integer, primary_key=True) + instrument_id = db.Column(db.Integer, db.ForeignKey('instruments.id'), + nullable=False, unique=True) + price = db.Column(db.Float, nullable=False) + prev_close = db.Column(db.Float, nullable=False) + change = db.Column(db.Float, nullable=False) + change_pct = db.Column(db.Float, nullable=False, index=True) + day_open = db.Column(db.Float) + day_high = db.Column(db.Float) + day_low = db.Column(db.Float) + wk52_high = db.Column(db.Float) + wk52_low = db.Column(db.Float) + volume = db.Column(db.BigInteger, index=True) + avg_volume = db.Column(db.BigInteger) + shares_outstanding = db.Column(db.BigInteger) + mkt_cap = db.Column(db.Float, index=True) + eps = db.Column(db.Float) + pe_ratio = db.Column(db.Float) + beta = db.Column(db.Float) + dividend_yield = db.Column(db.Float) + quarterly_dividend = db.Column(db.Float) + ex_dividend_date = db.Column(db.String(40)) + after_price = db.Column(db.Float) + after_change = db.Column(db.Float) + after_change_pct = db.Column(db.Float) + + +class PriceHistory(db.Model): + __tablename__ = 'price_history' + id = db.Column(db.Integer, primary_key=True) + instrument_id = db.Column(db.Integer, db.ForeignKey('instruments.id'), + nullable=False, unique=True) + daily_json = db.Column(db.Text, nullable=False) # closes, oldest first + volume_json = db.Column(db.Text, nullable=False) + intraday_json = db.Column(db.Text, nullable=False) # final session + intraday_volume_json = db.Column(db.Text, default='[]') + intraday5d_json = db.Column(db.Text, nullable=False) + after_json = db.Column(db.Text, default='[]') # post-close leg + + def closes(self): + return json.loads(self.daily_json) + + def volumes(self): + return json.loads(self.volume_json) + + def intraday(self): + return json.loads(self.intraday_json) + + def intraday_volumes(self): + return json.loads(self.intraday_volume_json or '[]') + + def intraday5d(self): + return json.loads(self.intraday5d_json) + + def after(self): + return json.loads(self.after_json or '[]') + + +class FinancialRow(db.Model): + __tablename__ = 'financial_rows' + id = db.Column(db.Integer, primary_key=True) + instrument_id = db.Column(db.Integer, db.ForeignKey('instruments.id'), + nullable=False, index=True) + statement = db.Column(db.String(16), nullable=False) # income|balance|cashflow + period_type = db.Column(db.String(12), nullable=False) # quarterly|annual + period_label = db.Column(db.String(20), nullable=False) + ordinal = db.Column(db.Integer, default=0) + items_json = db.Column(db.Text, nullable=False) + + def items(self): + return json.loads(self.items_json) + + +class EarningsRow(db.Model): + __tablename__ = 'earnings_rows' + id = db.Column(db.Integer, primary_key=True) + instrument_id = db.Column(db.Integer, db.ForeignKey('instruments.id'), + nullable=False, index=True) + quarter = db.Column(db.String(20), nullable=False) + report_date = db.Column(db.String(12), nullable=False) + eps_estimate = db.Column(db.Float) + eps_actual = db.Column(db.Float) + revenue_estimate = db.Column(db.Float) + revenue_actual = db.Column(db.Float) + surprise_pct = db.Column(db.Float) + + +class KeyMoment(db.Model): + """A dated event Google marks with an orange halo on the long-range chart.""" + __tablename__ = 'key_moments' + id = db.Column(db.Integer, primary_key=True) + instrument_id = db.Column(db.Integer, db.ForeignKey('instruments.id'), + nullable=False, index=True) + moment_date = db.Column(db.String(12), nullable=False, index=True) + kind = db.Column(db.String(16), nullable=False) # earnings | move + title = db.Column(db.String(200), nullable=False) + + +class AnalystRating(db.Model): + __tablename__ = 'analyst_ratings' + id = db.Column(db.Integer, primary_key=True) + instrument_id = db.Column(db.Integer, db.ForeignKey('instruments.id'), + nullable=False, index=True) + firm = db.Column(db.String(80), nullable=False) + rating = db.Column(db.String(20), nullable=False) + price_target = db.Column(db.Float) + rated_on = db.Column(db.String(12)) + + +class EtfHolding(db.Model): + __tablename__ = 'etf_holdings' + id = db.Column(db.Integer, primary_key=True) + etf_id = db.Column(db.Integer, db.ForeignKey('instruments.id'), + nullable=False, index=True) + holding_id = db.Column(db.Integer, db.ForeignKey('instruments.id'), + nullable=False) + weight_pct = db.Column(db.Float, nullable=False) + + etf = db.relationship('Instrument', foreign_keys=[etf_id]) + holding = db.relationship('Instrument', foreign_keys=[holding_id]) + + +class InstitutionalHolder(db.Model): + """A fund or manager on a stock's Holdings tab.""" + __tablename__ = 'institutional_holders' + id = db.Column(db.Integer, primary_key=True) + instrument_id = db.Column(db.Integer, db.ForeignKey('instruments.id'), + nullable=False, index=True) + firm = db.Column(db.String(80), nullable=False) + pct_held = db.Column(db.Float, nullable=False) + shares = db.Column(db.BigInteger) + value = db.Column(db.Float) + change_pct = db.Column(db.Float) + + +class Publisher(db.Model): + __tablename__ = 'publishers' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), nullable=False) + domain = db.Column(db.String(120), unique=True, nullable=False) + favicon_file = db.Column(db.String(160), default='') + + @property + def favicon_url(self): + if self.favicon_file: + return url_for('static', filename=f'images/{self.favicon_file}') + return None + + +class NewsArticle(db.Model): + __tablename__ = 'news_articles' + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(200), unique=True, nullable=False, index=True) + headline = db.Column(db.String(300), nullable=False) + publisher_id = db.Column(db.Integer, db.ForeignKey('publishers.id')) + published_at = db.Column(db.DateTime, nullable=False, index=True) + summary = db.Column(db.Text, default='') + body = db.Column(db.Text, default='') + scope = db.Column(db.String(16), default='company', index=True) # market|company + region = db.Column(db.String(16), default='us', index=True) + + publisher = db.relationship('Publisher') + links = db.relationship('NewsLink', backref='article', lazy=True, + cascade='all, delete-orphan') + + def ago(self): + return humanize_ago(self.published_at) + + +class NewsLink(db.Model): + __tablename__ = 'news_links' + id = db.Column(db.Integer, primary_key=True) + news_id = db.Column(db.Integer, db.ForeignKey('news_articles.id'), + nullable=False, index=True) + instrument_id = db.Column(db.Integer, db.ForeignKey('instruments.id'), + nullable=False, index=True) + instrument = db.relationship('Instrument') + + +class MarketSummary(db.Model): + __tablename__ = 'market_summaries' + id = db.Column(db.Integer, primary_key=True) + region = db.Column(db.String(16), nullable=False, index=True) + headline = db.Column(db.String(300), nullable=False) + body = db.Column(db.Text, nullable=False) + sources_count = db.Column(db.Integer, default=3) + rank = db.Column(db.Integer, default=0) + + +class Watchlist(db.Model): + __tablename__ = 'watchlists' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, + index=True) + name = db.Column(db.String(80), nullable=False) + items = db.relationship('WatchlistItem', backref='watchlist', lazy=True, + cascade='all, delete-orphan') + + +class WatchlistItem(db.Model): + __tablename__ = 'watchlist_items' + id = db.Column(db.Integer, primary_key=True) + watchlist_id = db.Column(db.Integer, db.ForeignKey('watchlists.id'), + nullable=False, index=True) + instrument_id = db.Column(db.Integer, db.ForeignKey('instruments.id'), + nullable=False) + instrument = db.relationship('Instrument') + + +class Portfolio(db.Model): + __tablename__ = 'portfolios' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, + index=True) + name = db.Column(db.String(80), nullable=False) + cash = db.Column(db.Float, default=0.0) + lots = db.relationship('PortfolioLot', backref='portfolio', lazy=True, + cascade='all, delete-orphan') + + def market_value(self): + return sum(l.market_value() for l in self.lots) + (self.cash or 0.0) + + def cost_basis_total(self): + return sum(l.shares * l.cost_basis for l in self.lots) + + def gain(self): + return sum(l.gain() for l in self.lots) + + def gain_pct(self): + cb = self.cost_basis_total() + return (self.gain() / cb * 100) if cb else 0.0 + + def day_change(self): + return sum(l.day_change() for l in self.lots) + + +class PortfolioLot(db.Model): + __tablename__ = 'portfolio_lots' + id = db.Column(db.Integer, primary_key=True) + portfolio_id = db.Column(db.Integer, db.ForeignKey('portfolios.id'), + nullable=False, index=True) + instrument_id = db.Column(db.Integer, db.ForeignKey('instruments.id'), + nullable=False) + shares = db.Column(db.Float, nullable=False) + cost_basis = db.Column(db.Float, nullable=False) + purchased_on = db.Column(db.String(12), default='') + instrument = db.relationship('Instrument') + + def market_value(self): + q = self.instrument.quote + return self.shares * (q.price if q else 0.0) + + def gain(self): + return self.market_value() - self.shares * self.cost_basis + + def gain_pct(self): + base = self.shares * self.cost_basis + return (self.gain() / base * 100) if base else 0.0 + + def day_change(self): + q = self.instrument.quote + return self.shares * (q.change if q else 0.0) + + +@login_manager.user_loader +def load_user(user_id): + return db.session.get(User, int(user_id)) + + +# --------------------------------------------------------------------------- +# Forms +# --------------------------------------------------------------------------- + +class RegisterForm(FlaskForm): + name = StringField('Name', validators=[DataRequired(), Length(max=120)]) + username = StringField('Username', + validators=[DataRequired(), Length(min=3, max=80)]) + email = StringField('Email', validators=[DataRequired(), Email()]) + password = PasswordField('Password', + validators=[DataRequired(), Length(min=8)]) + confirm = PasswordField('Confirm password', + validators=[DataRequired(), EqualTo('password')]) + + +class LoginForm(FlaskForm): + email = StringField('Email', validators=[DataRequired()]) + password = PasswordField('Password', validators=[DataRequired()]) + + +# --------------------------------------------------------------------------- +# Frozen-clock helpers +# --------------------------------------------------------------------------- + +NOW = datetime.combine(MD.MARKET_DATE, datetime.min.time()) + timedelta(hours=20) + + +def humanize_ago(dt): + """"3 hours ago" relative to the frozen market clock, never wall time.""" + mins = int((NOW - dt).total_seconds() // 60) + if mins < 1: + return 'just now' + if mins < 60: + return f'{mins} min. ago' + hours = mins // 60 + if hours < 24: + return f'{hours} hour{"s" if hours != 1 else ""} ago' + days = hours // 24 + if days < 30: + return f'{days} day{"s" if days != 1 else ""} ago' + months = days // 30 + return f'{months} month{"s" if months != 1 else ""} ago' + + +# --------------------------------------------------------------------------- +# Series slicing — every range derives from one stored daily series +# --------------------------------------------------------------------------- + +def series_for(inst, range_key): + """Everything the chart needs for one range, as a single dict. + + Keys: labels, values, volumes, after (post-close leg), after_labels, + baseline (previous close, only where Google draws the dashed line), + and `dates` for the hover read-out. + """ + h = inst.history + empty = {'labels': [], 'values': [], 'volumes': [], 'after': [], + 'after_labels': [], 'baseline': None, 'dates': []} + if not h: + return empty + + has_volume = inst.kind in ('stock', 'etf', 'crypto') + if range_key == '1D': + vals = h.intraday() + after = h.after() if inst.kind in ('stock', 'etf') else [] + stamp = MD.MARKET_DATE.strftime('%b %-d') + # Axis ticks sit on round clock hours, not on evenly spaced sample + # indices, so the labels read 12:00 PM / 3:00 PM the way Google's do. + open_m, close_m, end_m = 9 * 60 + 30, 16 * 60, 20 * 60 + def minute_of(i): + if i < len(vals): + return open_m + (close_m - open_m) * i / max(1, len(vals) - 1) + j = i - len(vals) + return close_m + (end_m - close_m) * j / max(1, len(after) - 1) + total = len(vals) + len(after) + targets = [720, 900, 1080] if after else [660, 780, 900] + tick_idx = [min(range(total), key=lambda i: abs(minute_of(i) - t)) + for t in targets] + return { + 'labels': MD.session_times(len(vals)), + 'values': vals, + 'volumes': h.intraday_volumes() if has_volume else [], + 'after': after, + 'after_labels': MD.after_hours_times(len(after)), + 'baseline': inst.quote.prev_close if inst.quote else None, + 'dates': [stamp] * total, + 'iso': [MD.MARKET_DATE.isoformat()] * total, + 'tick_idx': tick_idx, + } + + closes, dailies = h.closes(), h.volumes() + if range_key == '5D': + vals = h.intraday5d() + days = MD.CALENDAR[-5:] + per = max(1, len(vals) // len(days)) + idx = [min(len(days) - 1, i // per) for i in range(len(vals))] + # spread each session's volume across that session's points + vols = ([dailies[-5 + k] // per for k in idx] + if has_volume else []) + # one tick per session, placed at that day's first sample + tick_idx = [idx.index(d) for d in range(len(days)) if d in idx] + return {'labels': [days[k].strftime('%b %-d') for k in idx], + 'values': vals, 'volumes': vols, 'after': [], + 'after_labels': [], 'baseline': closes[-6], + 'dates': [days[k].strftime('%b %-d, %Y') for k in idx], + 'iso': [days[k].isoformat() for k in idx], + 'tick_idx': tick_idx} + + n = MD.RANGE_SESSIONS.get(range_key) + if range_key == 'YTD': + n = MD.ytd_sessions() + n = min(n or len(closes), len(closes)) + vals, vols, dates = closes[-n:], dailies[-n:], MD.CALENDAR[-n:] + step = max(1, len(vals) // 260) # keep charts readable + vals, vols, dates = vals[::step], vols[::step], dates[::step] + + # Ticks land on the first sample of a month (or of a year on MAX), the way + # Google labels its long ranges — evenly spaced indices give arbitrary + # dates like "Mar 13". + span_days = (dates[-1] - dates[0]).days + if span_days > 1500: + fmt, key = '%Y', lambda d: d.year + elif span_days > 400: + fmt, key = '%b %Y', lambda d: (d.year, (d.month - 1) // 3) + elif span_days > 70: + fmt, key = '%b %Y', lambda d: (d.year, d.month) + else: + # a one-month window spans at most two months, so label whole weeks + fmt, key = '%b %-d', lambda d: d.isocalendar()[:2] + firsts, seen = [], set() + for i, d in enumerate(dates): + k = key(d) + if k not in seen: + seen.add(k) + firsts.append(i) + if len(firsts) > 7: # thin out to ~6 labels + every = max(1, round(len(firsts) / 6)) + firsts = firsts[::every] + tick_idx = [i for i in firsts if i > 2] + + return {'labels': [d.strftime(fmt) for d in dates], 'values': vals, + 'volumes': vols if has_volume else [], + 'after': [], 'after_labels': [], 'baseline': None, + 'dates': [d.strftime('%b %-d, %Y') for d in dates], + 'iso': [d.isoformat() for d in dates], 'tick_idx': tick_idx} + + +MA_WINDOWS = {50: '#1a73e8', 200: '#e37400'} + + +def moving_averages(inst, ser, windows): + """SMA overlays for the Indicators menu. + + Averaged over the untruncated daily closes, not the plotted (possibly + downsampled) points, so a 200-day line really is a 200-day mean. + """ + if not windows or not inst.history: + return [] + closes = inst.history.closes() + n = len(ser['values']) + if n < 5 or len(closes) < max(windows) + n: + return [] + step = max(1, (MD.RANGE_SESSIONS.get('MAX') or 1) and 1) + out = [] + for w in sorted(windows): + if w not in MA_WINDOWS: + continue + tail = closes[-(n * step):] + series = [] + for k in range(n): + end = len(closes) - n * step + k * step + 1 + window = closes[max(0, end - w):end] + series.append(round(sum(window) / len(window), 4) if window else None) + out.append({'label': f'{w}-day SMA', 'colour': MA_WINDOWS[w], + 'values': series}) + return out + + +def moments_for(inst, ser): + """Key moments that fall inside the plotted window, as chart indices.""" + iso = ser.get('iso') or [] + if not iso or len(set(iso)) < 2: # 1D/5D windows are a single session + return [] + rows = (KeyMoment.query.filter_by(instrument_id=inst.id) + .filter(KeyMoment.moment_date >= iso[0], + KeyMoment.moment_date <= iso[-1]) + .order_by(KeyMoment.moment_date).all()) + out, used = [], set() + for m in rows: + # nearest plotted sample at or after the moment's date + i = next((k for k, d in enumerate(iso) if d >= m.moment_date), None) + if i is None or i in used: + continue + used.add(i) + out.append({'i': i, 'title': m.title, 'kind': m.kind, + 'date': m.moment_date}) + return out + + +def ranges_with_moments(inst): + """Which range tabs get the orange dot, matching the live tab strip.""" + if inst.kind not in ('stock', 'etf'): + return set() + rows = [m.moment_date for m in + KeyMoment.query.filter_by(instrument_id=inst.id).all()] + if not rows: + return set() + out = set() + for key in MD.RANGE_KEYS: + n = MD.RANGE_SESSIONS.get(key) + if key == 'YTD': + n = MD.ytd_sessions() + if not n: + continue + start = MD.CALENDAR[-min(n, len(MD.CALENDAR))].isoformat() + if any(d >= start for d in rows): + out.add(key) + return out + + +RANGE_WORDS = {'1D': 'Today', '5D': '5 days', '1M': '1 month', '6M': '6 months', + 'YTD': 'Year to date', '1Y': '1 year', '5Y': '5 years', + 'MAX': 'Max'} + + +def range_move(inst, ser, range_key): + """(abs change, pct change, label) across the selected range. + + Google's price header tracks the chart, not the session: switching to 1Y + turns "+2.68% (+7.86) Today" into "+19.22% (+33.34) 1 year". + """ + vals = ser['values'] + if len(vals) < 2: + return None, None, RANGE_WORDS.get(range_key, range_key) + base = ser.get('baseline') or vals[0] + last = (ser['after'] or vals)[-1] if range_key == '1D' else vals[-1] + if range_key == '1D' and inst.quote: + base, last = inst.quote.prev_close, inst.quote.price + if not base: + return None, None, RANGE_WORDS.get(range_key, range_key) + return (round(last - base, 4), round((last - base) / base * 100, 2), + RANGE_WORDS.get(range_key, range_key)) + + +def range_change(inst, range_key): + """Percent change across the selected range.""" + ser = series_for(inst, range_key) + return range_move(inst, ser, range_key)[1] + + +def sparkline(inst, n=40): + """A short close series for list rows and cards.""" + h = inst.history + if not h: + return [] + vals = h.intraday() + step = max(1, len(vals) // n) + return vals[::step] + + +# --------------------------------------------------------------------------- +# Charts — rendered server-side as inline SVG +# +# Google Finance draws its charts client-side, but a browser-driving agent +# only ever sees the rendered result. Emitting the SVG from the server keeps +# the page deterministic (no animation or fetch timing to race), keeps the +# image self-contained, and means screenshots of the same URL are identical +# every run — which the reset invariant and the reviewer both depend on. +# --------------------------------------------------------------------------- + +UP = '#137333' +DOWN = '#a50e0e' + + +def nice_ticks(lo, hi, target=6): + """Round axis values, the way Google labels its price axis. + + Slicing the range into equal fractions gives ticks like 213.70 / 211.62; + real charts step by 1, 2, 2.5 or 5 times a power of ten so the labels read + as round numbers. + """ + span = hi - lo + if span <= 0: + return [lo] + raw = span / max(1, target) + mag = 10 ** math.floor(math.log10(raw)) + for mult in (1, 2, 2.5, 5, 10): + if raw <= mult * mag: + step = mult * mag + break + else: + step = 10 * mag + first = math.ceil(lo / step) * step + out, v = [], first + while v <= hi + step * 1e-9: + out.append(round(v, 10)) + v += step + return out + + +def pv(inst, value): + """Format a price the way Google Finance does for this instrument kind.""" + if value is None: + return '—' + if inst.kind == 'currency': + return f'{value:,.4f}' + if inst.kind in ('index', 'sector'): + return f'{value:,.2f}' + return MD.fmt_price(value) + + +def _path_points(values, w, h, pad_top=8, pad_bottom=8): + lo, hi = min(values), max(values) + span = (hi - lo) or (abs(hi) * 0.01 or 1.0) + inner = h - pad_top - pad_bottom + n = len(values) + return [(round(i * w / max(1, n - 1), 2), + round(pad_top + inner - (v - lo) / span * inner, 2)) + for i, v in enumerate(values)], lo, hi + + +def spark_svg(inst, w=72, h=26, area=False): + """A card sparkline. In `area` mode it also draws the dotted previous-close + line the live index cards carry, so the fill reads as gain vs. loss rather + than as an arbitrary shape.""" + vals = sparkline(inst, 48) + if len(vals) < 2: + return Markup('') + q = inst.quote + up = bool(q and q.change_pct >= 0) + trend = 'up' if up else 'down' + pad = 3 if not area else 0 + + prev = q.prev_close if (area and q and q.prev_close) else None + lo, hi = min(vals), max(vals) + if prev: + lo, hi = min(lo, prev), max(hi, prev) + span = (hi - lo) or (abs(hi) * 0.01 or 1.0) + inner = h - pad * 2 + + def yv(v): + return round(pad + inner - (v - lo) / span * inner, 2) + + n = len(vals) + pts = [(round(i * w / (n - 1), 2), yv(v)) for i, v in enumerate(vals)] + d = 'M' + ' L'.join(f'{x},{y}' for x, y in pts) + + body = '' + if area: + body = (f'') + if prev: + by = yv(prev) + body += (f'') + return Markup( + f'') + + +def chart_svg(inst, ser, colour_up=True, chart_type='area', + overlays=None): + """The main price chart, drawn to match Google Finance's own layout. + + Rendered server-side so the shape of the page is deterministic: same URL, + same bytes, no fetch or animation timing for a screenshot to race. The + hover crosshair is layered on top by finance.js from the same data, which + adds interaction without changing what the page reports. + """ + values, after = ser['values'], ser['after'] + if len(values) < 2: + return Markup('
No price history available.
') + + W, H = 1000, 280 + PAD_L, PAD_R, AXIS_H = 58, 16, 24 + PLOT_TOP, PLOT_BOT = 10, H - AXIS_H + VOL_H = 46 if (ser.get('volumes')) else 0 # volume band along the bottom + PRICE_BOT = PLOT_BOT - VOL_H - (4 if VOL_H else 0) + plot_w = W - PAD_L - PAD_R + + baseline = ser.get('baseline') + combined = values + after + n = len(combined) + scale_src = combined + ([baseline] if baseline else []) + lo, hi = min(scale_src), max(scale_src) + span = (hi - lo) or (abs(hi) * 0.01 or 1.0) + lo -= span * 0.10 + hi += span * 0.10 + span = hi - lo + + def px(i): + return round(PAD_L + i * plot_w / max(1, n - 1), 2) + + def py(v): + return round(PRICE_BOT - (v - lo) / span * (PRICE_BOT - PLOT_TOP), 2) + + trend = 'up' if colour_up else 'down' + + # --- volume bars, drawn first so the price line sits above them -------- + vols = ser.get('volumes') or [] + bars = [] + if vols: + vmax = max(vols) or 1 + bw = max(1.0, plot_w / max(1, n) * 0.62) + prev = values[0] + for i, v in enumerate(vols[:len(values)]): + rising = values[i] >= prev + prev = values[i] + h = max(1.0, v / vmax * VOL_H) + bars.append(f'') + + # --- gridlines: horizontal with left-hand price labels, vertical ticks -- + grid, ylabels = [], [] + base_y = py(baseline) if baseline else None + ticks_v = nice_ticks(lo + span * 0.06, hi - span * 0.06) + # Google drops the decimals when every tick is a whole number, so an index + # axis reads 7,460 / 7,440 rather than 7,460.00 / 7,440.00. + digits = MD.digits_for(inst.kind, values[-1]) + if all(abs(v - round(v)) < 1e-9 for v in ticks_v): + digits = 0 + for v in ticks_v: + y = py(v) + grid.append(f'') + ylabels.append(f'{v:,.{digits}f}') + + labels = ser['labels'] + ser.get('after_labels', []) + tick_idx = ser.get('tick_idx') or [min(n - 1, int((k + 0.5) * n / 4)) + for k in range(4)] + ticks, xlabels = [], [] + for i in tick_idx: + if not 0 <= i < n: + continue + x = px(i) + ticks.append(f'') + xlabels.append(f'{labels[i]}') + + # --- regular-session path --------------------------------------------- + pts = [(px(i), py(v)) for i, v in enumerate(values)] + line = 'M' + ' L'.join(f'{x},{y}' for x, y in pts) + paths = [] + if chart_type == 'area': + area = f'{line} L{pts[-1][0]},{PLOT_BOT} L{pts[0][0]},{PLOT_BOT} Z' + paths.append(f'') + paths += bars # volume sits on top of the fill + paths.append(f'') + + for ov in (overlays or []): + seg, started = [], False + for i, v in enumerate(ov['values']): + if v is None: + started = False + continue + seg.append(("M" if not started else "L") + f'{px(i)},{py(v)}') + started = True + if seg: + paths.append(f'') + + # --- after-hours leg, grey, behind a session divider ------------------- + if after: + j = len(values) - 1 + apts = [(px(j), py(values[-1]))] + \ + [(px(len(values) + i), py(v)) for i, v in enumerate(after)] + aline = 'M' + ' L'.join(f'{x},{y}' for x, y in apts) + paths.append(f'') + paths.append(f'') + dot = apts[-1] + else: + dot = pts[-1] + + base_el = '' + if baseline: + base_el = (f'' + f'' + f'Prev close {pv(inst, baseline)}') + + end_dot = (f'') + + # key moments: an amber halo with a dot, as Google marks earnings and + # outsized sessions on the 6M-and-longer charts + moments = ser.get('moments') or [] + halos = [] + for m in moments: + i = m['i'] + if not 0 <= i < len(values): + continue + mx, my = px(i), py(values[i]) + halos.append( + f'{m["date"]} — {m["title"]}') + + payload = json.dumps({ + 'labels': labels, + 'dates': ser.get('dates') or labels, + 'values': [round(v, 6) for v in combined], + 'volumes': [int(v) for v in vols] if vols else [], + 'split': len(values) if after else None, + 'baseline': baseline, + 'padL': PAD_L, 'padR': PAD_R, 'width': W, + 'moments': [{'i': m['i'], 'title': m['title']} for m in moments], + 'digits': MD.digits_for(inst.kind, values[-1]), + 'prefix': '' if inst.kind in ('index', 'sector', 'currency') else '$', + }, separators=(',', ':')) + + return Markup( + f'
' + f'' + # The fill fades out downwards, as on the live chart. Stop colours are + # set in CSS so the theme and the up/down convention can swap them. + '' + '' + '' + '' + '' + '' + '' + '' + + ''.join(grid) + ''.join(ticks) + ''.join(paths) + + base_el + ''.join(halos) + end_dot + ''.join(ylabels) + + ''.join(xlabels) + + '' + + '' + f'' + f'' + '
') + + +def bar_chart_svg(labels, series, w=740, h=210): + """Grouped bar chart for the Financials tab (revenue vs net income).""" + flat = [v for _, vals, _ in series for v in vals] + if not flat: + return Markup('') + hi = max(max(flat), 0) + lo = min(min(flat), 0) + span = (hi - lo) or 1 + pad_b, pad_r = 24, 58 + plot_h, plot_w = h - pad_b, w - pad_r + groups = len(labels) + gw = plot_w / max(1, groups) + bw = gw / (len(series) + 1.6) + zero_y = plot_h - (0 - lo) / span * (plot_h - 12) - 6 + + bars, legend = [], [] + for si, (name, vals, colour) in enumerate(series): + legend.append(f'' + f'{name}') + for gi, v in enumerate(vals): + y = plot_h - (v - lo) / span * (plot_h - 12) - 6 + top, height = min(y, zero_y), abs(zero_y - y) + x = gi * gw + gw / 2 - (len(series) * bw) / 2 + si * bw + bars.append(f'') + xs = [f'{lab}' for gi, lab in enumerate(labels)] + ys = [] + for k in range(4): + v = lo + span * (k + 1) / 5 + y = plot_h - (v - lo) / span * (plot_h - 12) - 6 + ys.append(f'{MD.fmt_big(v)}') + return Markup( + f'
{"".join(legend)}
' + f'' + + ''.join(ys) + ''.join(bars) + ''.join(xs) + '') + + +# --------------------------------------------------------------------------- +# Search — scored token overlap, never strict AND +# --------------------------------------------------------------------------- + +def _tokens(q): + return [t.lower() for t in re.findall(r'[a-z0-9.\-=]+', q.lower()) + if t not in STOPWORDS and len(t) >= 1] + + +# Everyday words that map onto a GICS sector or an industry label. Without +# these, a plain-language query like "bank" only matches the one company with +# "Bank" in its registered name, which reads as a broken search. +SEARCH_ALIASES = { + 'bank': 'Financials', 'banks': 'Financials', 'banking': 'Financials', + 'finance': 'Financials', 'financial': 'Financials', + 'insurer': 'Financials', 'payments': 'Financials', + 'chip': 'Technology', 'chips': 'Technology', + 'semiconductor': 'Technology', 'semiconductors': 'Technology', + 'software': 'Technology', 'tech': 'Technology', 'cloud': 'Technology', + 'hardware': 'Technology', 'ai': 'Technology', + 'oil': 'Energy', 'gas': 'Energy', 'crude': 'Energy', 'drilling': 'Energy', + 'refiner': 'Energy', 'refining': 'Energy', + 'pharma': 'Health Care', 'pharmaceutical': 'Health Care', + 'biotech': 'Health Care', 'medical': 'Health Care', + 'hospital': 'Health Care', 'healthcare': 'Health Care', + 'retail': 'Consumer Discretionary', 'retailer': 'Consumer Discretionary', + 'automaker': 'Consumer Discretionary', 'carmaker': 'Consumer Discretionary', + 'travel': 'Consumer Discretionary', 'restaurant': 'Consumer Discretionary', + 'beverage': 'Consumer Staples', 'grocery': 'Consumer Staples', + 'household': 'Consumer Staples', 'tobacco': 'Consumer Staples', + 'airline': 'Industrials', 'aerospace': 'Industrials', + 'defense': 'Industrials', 'railroad': 'Industrials', + 'machinery': 'Industrials', 'logistics': 'Industrials', + 'utility': 'Utilities', 'utilities': 'Utilities', 'power': 'Utilities', + 'electric': 'Utilities', + 'reit': 'Real Estate', 'property': 'Real Estate', + 'realestate': 'Real Estate', + 'chemical': 'Materials', 'chemicals': 'Materials', 'mining': 'Materials', + 'miner': 'Materials', 'metals': 'Materials', + 'media': 'Communication Services', 'telecom': 'Communication Services', + 'streaming': 'Communication Services', 'social': 'Communication Services', + 'etf': 'ETF', 'fund': 'ETF', 'index': 'index', 'crypto': 'crypto', + 'currency': 'currency', 'forex': 'currency', 'futures': 'future', +} + + +def _score_instrument(inst, tokens): + ticker = inst.ticker.lower() + name = inst.name.lower() + sector = (inst.sector or '').lower() + hay = ' '.join([ticker, name, sector, (inst.industry or '').lower(), + (inst.kind or '').lower(), (inst.profile or '')[:600].lower()]) + score = 0 + for t in tokens: + alias = SEARCH_ALIASES.get(t, '').lower() + if ticker == t: + score += 12 + elif ticker.startswith(t): + score += 6 + elif name.startswith(t): + score += 5 + elif t in name: + score += 3 + elif alias and (alias == sector or alias == (inst.kind or '').lower() + or alias in (inst.industry or '').lower()): + score += 2 + elif t in hay: + score += 1 + return score + + +def search_instruments(q, limit=40): + tokens = _tokens(q) + if not tokens: + return [] + scored = [(s, i) for i in Instrument.query.all() + if (s := _score_instrument(i, tokens)) > 0] + scored.sort(key=lambda x: (-x[0], x[1].ticker)) + return [i for _, i in scored[:limit]] + + +def search_news(q, limit=12): + tokens = _tokens(q) + if not tokens: + return [] + clauses = [NewsArticle.headline.ilike(f'%{t}%') for t in tokens] + clauses += [NewsArticle.summary.ilike(f'%{t}%') for t in tokens] + return (NewsArticle.query.filter(or_(*clauses)) + .order_by(NewsArticle.published_at.desc()).limit(limit).all()) + + +# --------------------------------------------------------------------------- +# Shared view data +# --------------------------------------------------------------------------- + +def sector_index_rows(): + return (Instrument.query.filter_by(kind='sector') + .order_by(Instrument.ticker).all()) + + +def user_watchlists(): + if not current_user.is_authenticated: + return [] + return (Watchlist.query.filter_by(user_id=current_user.id) + .order_by(Watchlist.id).all()) + + +def user_portfolios(): + if not current_user.is_authenticated: + return [] + return (Portfolio.query.filter_by(user_id=current_user.id) + .order_by(Portfolio.id).all()) + + +# --------------------------------------------------------------------------- +# Routes — market pages +# --------------------------------------------------------------------------- + +REGION_INDEX_TICKERS = { + 'us': ['.DJI', '.INX', '.IXIC', 'RUT', 'VIX'], + 'europe': ['DAX', 'UKX', 'PX1', 'I', 'SX5E'], + 'asia': ['NI225', '000001', 'HSI', 'SENSEX', 'NIFTY_50'], +} + + +def region_cards(region): + if region in REGION_INDEX_TICKERS: + tickers = REGION_INDEX_TICKERS[region] + rows = Instrument.query.filter(Instrument.ticker.in_(tickers)).all() + order = {t: i for i, t in enumerate(tickers)} + return sorted(rows, key=lambda i: order.get(i.ticker, 99)) + if region == 'currencies': + return (Instrument.query.filter_by(kind='currency') + .order_by(Instrument.id).limit(6).all()) + if region == 'crypto': + return (Instrument.query.filter_by(kind='crypto').join(Quote) + .order_by(Quote.mkt_cap.desc()).limit(6).all()) + if region == 'futures': + return (Instrument.query.filter_by(kind='future') + .order_by(Instrument.id).limit(6).all()) + if region == 'latam': + return (Instrument.query.filter_by(region='latam') + .order_by(Instrument.id).limit(6).all()) + return [] + + +def movers_query(kind, limit=25): + base = Instrument.query.filter(Instrument.kind.in_(['stock', 'etf'])).join(Quote) + if kind == 'gainers': + return base.order_by(Quote.change_pct.desc()).limit(limit).all() + if kind == 'losers': + return base.order_by(Quote.change_pct.asc()).limit(limit).all() + if kind == 'most-active': + return base.order_by(Quote.volume.desc()).limit(limit).all() + return [] + + +@app.context_processor +def inject_globals(): + return { + 'market_date': MD.MARKET_DATE, + 'market_close_label': MD.MARKET_CLOSE_LABEL, + 'after_hours_label': MD.AFTER_HOURS_LABEL, + 'current_year': MD.MARKET_YEAR, + 'regions': REGIONS, + 'market_pages': MARKET_PAGES, + 'sector_indexes': sector_index_rows(), + 'nav_watchlists': user_watchlists(), + 'nav_portfolios': user_portfolios(), + 'rail_gainers': movers_query('gainers', 4), + 'rail_losers': movers_query('losers', 4), + 'rail_news': (NewsArticle.query.filter_by(scope='market') + .order_by(NewsArticle.published_at.desc()).limit(5).all()), + 'fmt_big': MD.fmt_big, + 'fmt_price': MD.fmt_price, + 'fmt_pct': MD.fmt_pct, + 'fmt_money_signed': MD.fmt_money_signed, + 'sparkline': sparkline, + 'pv': pv, + 'spark_svg': spark_svg, + 'chart_svg': chart_svg, + 'bar_chart_svg': bar_chart_svg, + 'range_keys': MD.RANGE_KEYS, + 'theme': current_theme(), + 'colors': current_colors(), + 'themes': THEMES, + 'color_modes': COLOR_MODES, + 'here': request.full_path.rstrip('?') or '/', + } + + +@app.route('/') +def index(): + region = request.args.get('region', 'us') + if region not in {r for r, _ in REGIONS}: + region = 'us' + summaries = (MarketSummary.query.filter_by(region=region) + .order_by(MarketSummary.rank).all()) + if not summaries: + summaries = (MarketSummary.query.filter_by(region='us') + .order_by(MarketSummary.rank).all()) + # Google shows the favicons of the outlets a summary was drawn from, next + # to the "N sites" count. Pick them deterministically per summary. + pubs = Publisher.query.order_by(Publisher.id).all() + sources = {} + if pubs: + for row in summaries: + k = row.id * 7 + sources[row.id] = [pubs[(k + j) % len(pubs)] + for j in range(min(row.sources_count, 4))] + updates = (NewsArticle.query.filter_by(scope='market') + .order_by(NewsArticle.published_at.desc()).limit(14).all()) + movers = {'gainers': movers_query('gainers', 5), + 'losers': movers_query('losers', 5), + 'most-active': movers_query('most-active', 5)} + return render_template('index.html', region=region, + cards=region_cards(region), summaries=summaries, + sources=sources, updates=updates, movers=movers) + + +@app.route('/markets/') +def markets(slug): + titles = dict(MARKET_PAGES) + if slug not in titles: + abort(404) + if slug in ('gainers', 'losers', 'most-active'): + rows = movers_query(slug, 25) + elif slug == 'indexes': + rows = (Instrument.query.filter_by(kind='index') + .order_by(Instrument.region, Instrument.id).all()) + elif slug == 'cryptocurrencies': + rows = (Instrument.query.filter_by(kind='crypto').join(Quote) + .order_by(Quote.mkt_cap.desc()).all()) + elif slug == 'currencies': + rows = Instrument.query.filter_by(kind='currency').order_by( + Instrument.id).all() + elif slug == 'futures': + rows = Instrument.query.filter_by(kind='future').order_by( + Instrument.id).all() + elif slug == 'climate-leaders': + rows = (Instrument.query.filter_by(is_climate_leader=True) + .order_by(Instrument.ticker).all()) + else: + rows = [] + return render_template('markets.html', slug=slug, title=titles[slug], + rows=rows) + + +# --------------------------------------------------------------------------- +# Routes — quote +# --------------------------------------------------------------------------- + +QUOTE_TABS = ['overview', 'analysis', 'earnings', 'financials', 'holdings'] + + +def tabs_for(inst): + if inst.kind == 'stock': + # a stock's Holdings tab lists who owns it; an ETF's lists what it owns + return ['overview', 'analysis', 'earnings', 'financials', 'holdings'] + if inst.kind == 'etf': + return ['overview', 'holdings'] + return ['overview'] + + +@app.route('/quote/') +def quote(symbol): + inst = Instrument.query.filter_by(slug=symbol).first() + if not inst: + inst = Instrument.query.filter( + func.upper(Instrument.ticker) == symbol.split(':')[0].upper()).first() + if not inst: + abort(404) + + available = tabs_for(inst) + tab = request.args.get('tab', 'overview').lower() + if tab not in available: + tab = 'overview' + rng = request.args.get('range', '1D').upper() + if rng not in MD.RANGE_KEYS: + rng = '1D' + + chart_type = request.args.get('chart', 'area').lower() + if chart_type not in ('area', 'line'): + chart_type = 'area' + try: + ma = sorted({int(x) for x in request.args.get('ma', '').split(',') + if x.strip().isdigit()} & set(MA_WINDOWS)) + except ValueError: + ma = [] + + ser = series_for(inst, rng) + ser['moments'] = moments_for(inst, ser) + overlays = moving_averages(inst, ser, ma) + related = [Instrument.query.filter_by(slug=s).first() + for s in inst.related_slugs] + related = [r for r in related if r and r.id != inst.id][:6] + news = (NewsArticle.query.join(NewsLink, NewsLink.news_id == NewsArticle.id) + .filter(NewsLink.instrument_id == inst.id) + .order_by(NewsArticle.published_at.desc()).limit(10).all()) + + move_abs, move_pct, move_label = range_move(inst, ser, rng) + ctx = dict(inst=inst, q=inst.quote, tab=tab, tabs=available, rng=rng, + series=ser, related=related, news=news, + move_abs=move_abs, move_pct=move_pct, move_label=move_label, + range_pct=move_pct, moment_ranges=ranges_with_moments(inst), + moment_count=len(ser['moments']), chart_type=chart_type, + ma=ma, overlays=overlays, ma_windows=sorted(MA_WINDOWS)) + + if tab == 'financials': + stmt = request.args.get('statement', 'income').lower() + if stmt not in ('income', 'balance', 'cashflow'): + stmt = 'income' + period = request.args.get('period', 'quarterly').lower() + if period not in ('quarterly', 'annual'): + period = 'quarterly' + rows = (FinancialRow.query + .filter_by(instrument_id=inst.id, statement=stmt, + period_type=period) + .order_by(FinancialRow.ordinal).all()) + chart = None + if stmt == 'income' and rows: + tail = rows[-4:] + items = [r.items() for r in tail] + chart = { + 'labels': [r.period_label for r in tail], + 'series': [ + ('Revenue', [i['revenue'] for i in items], '#4285f4'), + ('Net income', [i['net_income'] for i in items], '#174ea6'), + ], + } + ctx.update(statement=stmt, period=period, fin_rows=rows, + fin_chart=chart) + elif tab == 'earnings': + ctx['earnings'] = (EarningsRow.query.filter_by(instrument_id=inst.id) + .order_by(EarningsRow.report_date.desc()).all()) + elif tab == 'analysis': + ratings = (AnalystRating.query.filter_by(instrument_id=inst.id) + .order_by(AnalystRating.rated_on.desc()).all()) + ctx['ratings'] = ratings + ctx['consensus'] = MD.rating_consensus( + [{'rating': r.rating, 'price_target': r.price_target} + for r in ratings]) if ratings else None + elif tab == 'holdings': + ctx['holdings'] = (EtfHolding.query.filter_by(etf_id=inst.id) + .order_by(EtfHolding.weight_pct.desc()).all()) + ctx['holders'] = (InstitutionalHolder.query + .filter_by(instrument_id=inst.id) + .order_by(InstitutionalHolder.pct_held.desc()).all()) + # funds in this mirror that hold the stock, from the ETF tables + ctx['held_by'] = (EtfHolding.query.filter_by(holding_id=inst.id) + .order_by(EtfHolding.weight_pct.desc()).all()) + + ctx['in_lists'] = [w.id for w in user_watchlists() + if any(i.instrument_id == inst.id for i in w.items)] + return render_template('quote.html', **ctx) + + +# --------------------------------------------------------------------------- +# Routes — search, news, tools +# --------------------------------------------------------------------------- + +@app.route('/search') +def search(): + q = request.args.get('q', '').strip() + return render_template('search.html', q=q, + results=search_instruments(q) if q else [], + news=search_news(q) if q else []) + + +@app.route('/autocomplete') +@csrf.exempt +def autocomplete(): + q = request.args.get('q', '').strip() + if not q: + return jsonify([]) + return jsonify([ + {'ticker': i.ticker, 'name': i.name, 'slug': i.slug, + 'exchange': i.exchange, 'kind': i.kind, + 'price': i.quote.price if i.quote else None, + 'change_pct': i.quote.change_pct if i.quote else None} + for i in search_instruments(q, limit=8)]) + + +@app.route('/news') +def news_index(): + scope = request.args.get('scope', 'all') + qry = NewsArticle.query + if scope in ('market', 'company'): + qry = qry.filter_by(scope=scope) + return render_template( + 'news_index.html', scope=scope, + articles=qry.order_by(NewsArticle.published_at.desc()).limit(60).all()) + + +@app.route('/news/') +def news_detail(slug): + a = NewsArticle.query.filter_by(slug=slug).first_or_404() + related = (NewsArticle.query + .filter(NewsArticle.id != a.id, NewsArticle.scope == a.scope) + .order_by(NewsArticle.published_at.desc()).limit(6).all()) + return render_template('news_detail.html', a=a, related=related) + + +@app.route('/currency-converter') +def currency_converter(): + pairs = Instrument.query.filter_by(kind='currency').order_by( + Instrument.id).all() + codes = [] + for p in pairs: + for c in p.ticker.split('-'): + if c not in codes: + codes.append(c) + src = request.args.get('from', 'USD').upper() + dst = request.args.get('to', 'EUR').upper() + try: + amount = float(request.args.get('amount', '1') or 1) + except ValueError: + amount = 1.0 + rate = result = err = None + if src == dst: + rate, result = 1.0, amount + else: + direct = Instrument.query.filter_by(kind='currency', + ticker=f'{src}-{dst}').first() + inverse = Instrument.query.filter_by(kind='currency', + ticker=f'{dst}-{src}').first() + if direct and direct.quote: + rate = direct.quote.price + elif inverse and inverse.quote and inverse.quote.price: + rate = 1.0 / inverse.quote.price + if rate: + result = amount * rate + else: + err = f'No quoted rate for {src} to {dst}.' + return render_template('converter.html', codes=codes, src=src, dst=dst, + amount=amount, rate=rate, result=result, err=err, + pairs=pairs) + + +@app.route('/compare') +def compare(): + raw = request.args.get('tickers', '') + rows = [] + for t in [x.strip() for x in raw.split(',') if x.strip()][:5]: + inst = (Instrument.query.filter_by(slug=t).first() + or Instrument.query.filter( + func.upper(Instrument.ticker) == t.upper()).first()) + if inst: + rows.append(inst) + return render_template('compare.html', rows=rows, raw=raw) + + +# --------------------------------------------------------------------------- +# Routes — auth & account +# --------------------------------------------------------------------------- + +@app.route('/register', methods=['GET', 'POST']) +def register(): + if current_user.is_authenticated: + return redirect(url_for('index')) + form = RegisterForm() + if form.validate_on_submit(): + if User.query.filter_by(email=form.email.data.lower()).first(): + flash('That email is already registered.', 'error') + elif User.query.filter_by(username=form.username.data).first(): + flash('That username is taken.', 'error') + else: + u = User(email=form.email.data.lower(), + username=form.username.data, name=form.name.data) + u.set_password(form.password.data) + db.session.add(u) + db.session.commit() + db.session.add(Watchlist(user_id=u.id, name='My list')) + db.session.commit() + login_user(u) + return redirect(url_for('index')) + return render_template('register.html', form=form) + + +@app.route('/login', methods=['GET', 'POST']) +def login(): + if current_user.is_authenticated: + return redirect(url_for('index')) + form = LoginForm() + if form.validate_on_submit(): + ident = form.email.data.strip().lower() + user = (User.query.filter_by(email=ident).first() + or User.query.filter(func.lower(User.username) == ident).first()) + if user and user.check_password(form.password.data): + login_user(user) + # `next` arrives as a query arg from @login_required and as a + # hidden field when the user came via "Use another account". + return redirect(_safe_next(request.form.get('next') + or request.args.get('next'))) + flash('Wrong email or password. Try again.', 'error') + return render_template('login.html', form=form) + + +# Avatar colours Google uses for accounts without a profile photo. +AVATAR_COLOURS = ['#1a73e8', '#d93025', '#188038', '#e37400', '#9334e6', + '#12b5cb', '#c5221f', '#1e8e3e'] + + +def avatar_for(user): + """A stable colour + initial per account, the way the picker renders one.""" + return {'colour': AVATAR_COLOURS[user.id % len(AVATAR_COLOURS)], + 'initial': (user.name or user.email)[0].upper()} + + +def _safe_next(raw): + """Only ever redirect back inside this site.""" + if raw and raw.startswith('/') and not raw.startswith('//'): + return raw + return url_for('index') + + +@app.route('/accounts/chooser') +def account_chooser(): + """The 'Choose an account' step, mirroring accounts.google.com. + + Two sign-in paths coexist on purpose. Typing an email and password + exercises form filling; picking a profile card exercises the click-through + path an agent meets on any site with a federated login. The picker asks + for no credential at all — it only lists the accounts already seeded in + this mirror's own database. + """ + if current_user.is_authenticated: + return redirect(_safe_next(request.args.get('next'))) + users = User.query.order_by(User.id).all() + return render_template('account_chooser.html', users=users, + avatar_for=avatar_for, + next_url=_safe_next(request.args.get('next'))) + + +@app.route('/accounts/choose', methods=['POST']) +def account_choose(): + email = (request.form.get('email') or '').strip().lower() + user = User.query.filter_by(email=email).first() + if not user: + flash('That account is not available on this device.', 'error') + return redirect(url_for('account_chooser')) + login_user(user) + return redirect(_safe_next(request.form.get('next'))) + + +@app.route('/logout') +def logout(): + logout_user() + return redirect(url_for('index')) + + +@app.route('/account') +@login_required +def account(): + return render_template('account.html') + + +# --------------------------------------------------------------------------- +# Routes — watchlists +# --------------------------------------------------------------------------- + +@app.route('/lists') +@login_required +def lists_index(): + return render_template('lists.html', lists=user_watchlists()) + + +@app.route('/lists/') +@login_required +def list_detail(list_id): + wl = Watchlist.query.filter_by(id=list_id, + user_id=current_user.id).first_or_404() + return render_template('list_detail.html', wl=wl) + + +@app.route('/lists/new', methods=['GET']) +@login_required +def list_new_form(): + """Standalone create-list page, the destination of the rail's + button.""" + return render_template('list_new.html') + + +@app.route('/lists/new', methods=['POST']) +@login_required +def list_new(): + name = (request.form.get('name') or '').strip() + if not name: + flash('Enter a name for the list.', 'error') + return redirect(request.referrer or url_for('lists_index')) + if Watchlist.query.filter_by(user_id=current_user.id, name=name).first(): + flash(f'You already have a list named "{name}".', 'error') + return redirect(request.referrer or url_for('lists_index')) + wl = Watchlist(user_id=current_user.id, name=name) + db.session.add(wl) + db.session.commit() + flash(f'Created list "{name}".', 'success') + return redirect(url_for('list_detail', list_id=wl.id)) + + +@app.route('/lists//add', methods=['POST']) +@login_required +def list_add(list_id): + wl = Watchlist.query.filter_by(id=list_id, + user_id=current_user.id).first_or_404() + slug = (request.form.get('slug') or '').strip() + inst = (Instrument.query.filter_by(slug=slug).first() + or Instrument.query.filter( + func.upper(Instrument.ticker) == slug.upper()).first()) + if not inst: + flash(f'No instrument matches "{slug}".', 'error') + elif WatchlistItem.query.filter_by(watchlist_id=wl.id, + instrument_id=inst.id).first(): + flash(f'{inst.ticker} is already in "{wl.name}".', 'info') + else: + db.session.add(WatchlistItem(watchlist_id=wl.id, instrument_id=inst.id)) + db.session.commit() + flash(f'Added {inst.ticker} to "{wl.name}".', 'success') + return redirect(request.referrer or url_for('list_detail', list_id=wl.id)) + + +@app.route('/lists//toggle', methods=['POST']) +@login_required +def list_toggle(list_id): + """Add or remove one symbol from the quote page's Add-to-list menu.""" + wl = Watchlist.query.filter_by(id=list_id, + user_id=current_user.id).first_or_404() + slug = (request.form.get('slug') or '').strip() + inst = (Instrument.query.filter_by(slug=slug).first() + or Instrument.query.filter( + func.upper(Instrument.ticker) == slug.upper()).first()) + if not inst: + flash(f'No instrument matches "{slug}".', 'error') + return redirect(request.referrer or url_for('index')) + item = WatchlistItem.query.filter_by(watchlist_id=wl.id, + instrument_id=inst.id).first() + if item: + db.session.delete(item) + db.session.commit() + flash(f'Removed {inst.ticker} from "{wl.name}".', 'info') + else: + db.session.add(WatchlistItem(watchlist_id=wl.id, instrument_id=inst.id)) + db.session.commit() + flash(f'Added {inst.ticker} to "{wl.name}".', 'success') + return redirect(request.referrer or url_for('quote', symbol=inst.slug)) + + +@app.route('/lists//remove/', methods=['POST']) +@login_required +def list_remove(list_id, item_id): + wl = Watchlist.query.filter_by(id=list_id, + user_id=current_user.id).first_or_404() + item = WatchlistItem.query.filter_by(id=item_id, + watchlist_id=wl.id).first_or_404() + ticker = item.instrument.ticker + db.session.delete(item) + db.session.commit() + flash(f'Removed {ticker} from "{wl.name}".', 'info') + return redirect(request.referrer or url_for('list_detail', list_id=wl.id)) + + +@app.route('/lists//delete', methods=['POST']) +@login_required +def list_delete(list_id): + wl = Watchlist.query.filter_by(id=list_id, + user_id=current_user.id).first_or_404() + name = wl.name + db.session.delete(wl) + db.session.commit() + flash(f'Deleted list "{name}".', 'info') + return redirect(url_for('lists_index')) + + +# --------------------------------------------------------------------------- +# Routes — portfolios +# --------------------------------------------------------------------------- + +@app.route('/portfolios') +@login_required +def portfolios_index(): + return render_template('portfolios.html', portfolios=user_portfolios()) + + +@app.route('/portfolios/') +@login_required +def portfolio_detail(pid): + p = Portfolio.query.filter_by(id=pid, + user_id=current_user.id).first_or_404() + return render_template('portfolio_detail.html', p=p) + + +@app.route('/portfolios/new', methods=['POST']) +@login_required +def portfolio_new(): + name = (request.form.get('name') or '').strip() + if not name: + flash('Enter a name for the portfolio.', 'error') + return redirect(request.referrer or url_for('portfolios_index')) + try: + cash = float(request.form.get('cash') or 0) + except ValueError: + flash('Cash must be a number.', 'error') + return redirect(request.referrer or url_for('portfolios_index')) + p = Portfolio(user_id=current_user.id, name=name, cash=max(0.0, cash)) + db.session.add(p) + db.session.commit() + flash(f'Created portfolio "{name}".', 'success') + return redirect(url_for('portfolio_detail', pid=p.id)) + + +@app.route('/portfolios//lots/new', methods=['POST']) +@login_required +def lot_new(pid): + p = Portfolio.query.filter_by(id=pid, + user_id=current_user.id).first_or_404() + slug = (request.form.get('slug') or '').strip() + inst = (Instrument.query.filter_by(slug=slug).first() + or Instrument.query.filter( + func.upper(Instrument.ticker) == slug.upper()).first()) + if not inst or not inst.quote: + flash(f'No instrument matches "{slug}".', 'error') + return redirect(url_for('portfolio_detail', pid=p.id)) + try: + shares = float(request.form.get('shares') or 0) + cost = float(request.form.get('cost_basis') or 0) + except ValueError: + flash('Shares and cost basis must be numbers.', 'error') + return redirect(url_for('portfolio_detail', pid=p.id)) + if shares <= 0: + flash('Shares must be greater than zero.', 'error') + return redirect(url_for('portfolio_detail', pid=p.id)) + if cost <= 0: + cost = inst.quote.price + db.session.add(PortfolioLot(portfolio_id=p.id, instrument_id=inst.id, + shares=shares, cost_basis=cost, + purchased_on=MD.MARKET_DATE.isoformat())) + db.session.commit() + flash(f'Added {shares:g} shares of {inst.ticker} to "{p.name}".', 'success') + return redirect(url_for('portfolio_detail', pid=p.id)) + + +@app.route('/portfolios//lots//delete', methods=['POST']) +@login_required +def lot_delete(pid, lot_id): + p = Portfolio.query.filter_by(id=pid, + user_id=current_user.id).first_or_404() + lot = PortfolioLot.query.filter_by(id=lot_id, + portfolio_id=p.id).first_or_404() + ticker = lot.instrument.ticker + db.session.delete(lot) + db.session.commit() + flash(f'Removed {ticker} from "{p.name}".', 'info') + return redirect(url_for('portfolio_detail', pid=p.id)) + + +@app.route('/portfolios//delete', methods=['POST']) +@login_required +def portfolio_delete(pid): + p = Portfolio.query.filter_by(id=pid, + user_id=current_user.id).first_or_404() + name = p.name + db.session.delete(p) + db.session.commit() + flash(f'Deleted portfolio "{name}".', 'info') + return redirect(url_for('portfolios_index')) + + +# --------------------------------------------------------------------------- +# Health & errors +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- +# Display settings (theme, up/down colour convention) +# +# Held in cookies and applied to server-side, so the first paint is +# already correct. Google offers the same two switches behind the header gear. +# --------------------------------------------------------------------------- + +THEMES = {'light': 'Light', 'dark': 'Dark'} +COLOR_MODES = {'local': 'Local market (green up)', + 'intl': 'International (red up)'} + + +def current_theme(): + v = request.cookies.get('gf_theme', 'light') + return v if v in THEMES else 'light' + + +def current_colors(): + v = request.cookies.get('gf_colors', 'local') + return v if v in COLOR_MODES else 'local' + + +@app.route('/settings/theme/', methods=['GET']) +def set_theme(value): + if value not in THEMES: + abort(404) + resp = redirect(_safe_next(request.args.get('next'))) + resp.set_cookie('gf_theme', value, max_age=60 * 60 * 24 * 365, + samesite='Lax') + return resp + + +@app.route('/settings/colors/', methods=['GET']) +def set_colors(value): + if value not in COLOR_MODES: + abort(404) + resp = redirect(_safe_next(request.args.get('next'))) + resp.set_cookie('gf_colors', value, max_age=60 * 60 * 24 * 365, + samesite='Lax') + return resp + + +@app.route('/_health') +def health(): + return {'ok': True, 'site': 'google_finance', + 'instruments': Instrument.query.count(), + 'news': NewsArticle.query.count(), + 'market_date': MD.MARKET_DATE.isoformat()} + + +@app.errorhandler(404) +def not_found(e): + return render_template('404.html'), 404 + + +@app.errorhandler(500) +def server_error(e): + return render_template('500.html'), 500 + + +# --------------------------------------------------------------------------- +# Bootstrap +# --------------------------------------------------------------------------- + +MODELS = dict(Instrument=Instrument, Quote=Quote, PriceHistory=PriceHistory, + FinancialRow=FinancialRow, EarningsRow=EarningsRow, + KeyMoment=KeyMoment, InstitutionalHolder=InstitutionalHolder, + AnalystRating=AnalystRating, EtfHolding=EtfHolding, + Publisher=Publisher, NewsArticle=NewsArticle, NewsLink=NewsLink, + MarketSummary=MarketSummary, User=User, Watchlist=Watchlist, + WatchlistItem=WatchlistItem, Portfolio=Portfolio, + PortfolioLot=PortfolioLot) + +from seed_data import (seed_benchmark_users, seed_market, # noqa: E402 + seed_news) + +with app.app_context(): + db.create_all() + seed_market(db, MODELS) + seed_news(db, MODELS) + seed_benchmark_users(db, MODELS, bcrypt) + + +if __name__ == '__main__': + port = int(os.environ.get('PORT', 5000)) + app.run(host='0.0.0.0', port=port, debug=False) diff --git a/sites/google_finance/requirements.txt b/sites/google_finance/requirements.txt new file mode 100644 index 00000000..b41fee5e --- /dev/null +++ b/sites/google_finance/requirements.txt @@ -0,0 +1,9 @@ +Flask==3.1.0 +Flask-SQLAlchemy==3.1.1 +Flask-Login==0.6.3 +Flask-WTF==1.2.2 +Flask-Bcrypt==1.0.1 +MarkupSafe==3.0.3 +SQLAlchemy==2.0.36 +WTForms==3.2.1 +email-validator==2.2.0 diff --git a/sites/google_finance/seed_data.py b/sites/google_finance/seed_data.py new file mode 100644 index 00000000..cacb324b --- /dev/null +++ b/sites/google_finance/seed_data.py @@ -0,0 +1,576 @@ +"""Idempotent seed for the Google Finance mirror. + +Runs at every container boot and every /reset/google_finance. Each seed +function early-returns on a populated DB so re-seeding is a genuine no-op — +a bare commit on a populated DB still bumps SQLite metadata and would break +the byte-identical reset invariant. + +Company identities, logos and profile prose come from `_companies.py`, which +is generated once from the recon harvest (`_dev/build_company_module.py`) and +committed. Nothing here reads scraped_data/ — that directory is gitignored and +dockerignored, so it does not exist inside the image. +""" +import json + +import _market_data as MD +from _companies import ANCHORS, COMPANIES, PUBLISHERS +from _content import (MARKET_SUMMARIES, build_company_news, build_market_news) +from _universe import (CRYPTO, CURRENCIES, ETFS, FUTURES, INDEX_LEVELS, + INDEXES, SECTOR_INDEXES, SHORT_NAMES, STOCKS) + +INTRADAY_POINTS = 79 # 5-minute bars across a 6.5-hour session +FIVEDAY_PER_SESSION = 13 # 30-minute bars + +# Companies flagged on the Climate leaders board. Fixed list so the page is +# stable across reseeds; chosen to span several sectors rather than only tech. +CLIMATE_LEADERS = { + 'MSFT', 'AAPL', 'NVDA', 'GOOGL', 'ORCL', 'CRM', 'ACN', + 'NEE', 'DUK', 'SO', 'AEP', + 'PG', 'KO', 'CL', 'KMB', + 'LIN', 'APD', 'ECL', + 'UNP', 'DE', 'HON', + 'V', 'MA', 'SPGI', + 'PLD', 'EQIX', +} + +# Fallback display names for symbols the recon pass could not name. +FALLBACK_NAMES = { + 'CL=F': 'Crude Oil', 'GC=F': 'Gold', 'SI=F': 'Silver', + 'NG=F': 'Natural Gas', 'ZC=F': 'Corn', 'ES=F': 'E-Mini S&P 500', +} + +FUTURE_UNITS = { + 'CL=F': 'USD / bbl', 'GC=F': 'USD / oz', 'SI=F': 'USD / oz', + 'NG=F': 'USD / MMBtu', 'ZC=F': 'USD / bu', 'ES=F': 'index points', +} + +# Magnitude fallbacks when the recon pass has no anchor for a symbol. +DEFAULT_LEVEL = { + 'stock': 120.0, 'etf': 260.0, 'index': 4200.0, 'sector': 900.0, + 'crypto': 180.0, 'currency': 1.10, 'future': 78.0, +} + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + +def _anchor(slug, field, default=None): + a = ANCHORS.get(slug) or {} + v = a.get(field) + return v if v not in (None, '') else default + + +def _level_for(slug, kind): + """Price level for a symbol, nudged off its anchor. + + The anchor is a real open price (or a hand-set round number for crypto and + FX). Shipping it unchanged would both look artificial — BTC at exactly + 108,000.00 — and imply the mirror serves live values. The nudge is + deterministic, so the frozen number is stable across reseeds. + """ + base = (_anchor(slug, 'price') or INDEX_LEVELS.get(slug) + or DEFAULT_LEVEL[kind]) + return base * MD.rng_for(slug, 'level').uniform(0.93, 1.07) + + +def _five_day_path(symbol, closes, kind='stock'): + """Concatenated intraday paths for the last five sessions.""" + out = [] + for k in range(5, 0, -1): + seg = MD.intraday_path(symbol, closes[-k - 1], closes[-k], + FIVEDAY_PER_SESSION, 0.010, salt=f'5d{k}', + kind=kind) + out.extend(seg) + return out + + +def _make_series(db, M, inst, kind, sector, level): + closes = MD.daily_closes(inst.slug, level, kind, sector) + volumes = MD.daily_volumes(inst.slug, 1e7) + sigma = MD.CLASS_PARAMS.get(kind, MD.CLASS_PARAMS['stock'])[0] + intraday = MD.intraday_path(inst.slug, closes[-2], closes[-1], + INTRADAY_POINTS, sigma, kind=kind) + five = _five_day_path(inst.slug, closes, kind) + intra_vol = MD.intraday_volumes(inst.slug, volumes[-1], INTRADAY_POINTS) + db.session.add(M['PriceHistory']( + instrument_id=inst.id, + daily_json=json.dumps(closes), + volume_json=json.dumps(volumes), + intraday_json=json.dumps(intraday), + intraday_volume_json=json.dumps(intra_vol), + intraday5d_json=json.dumps(five), + after_json='[]')) + return closes, volumes, intraday + + +def _add_quote_full(db, M, inst, sector, closes, volumes, intraday): + anchors = { + 'mkt_cap': _anchor(inst.slug, 'mkt_cap'), + 'avg_volume': _anchor(inst.slug, 'avg_volume'), + } + s = MD.equity_stats(inst.slug, sector, closes, volumes, intraday, anchors) + db.session.add(M['Quote']( + instrument_id=inst.id, price=s['price'], prev_close=s['prev_close'], + change=s['change'], change_pct=s['change_pct'], + day_open=s['day_open'], day_high=s['day_high'], day_low=s['day_low'], + wk52_high=s['wk52_high'], wk52_low=s['wk52_low'], + volume=s['volume'], avg_volume=s['avg_volume'], + shares_outstanding=s['shares_outstanding'], mkt_cap=s['mkt_cap'], + eps=s['eps'], pe_ratio=s['pe_ratio'], beta=s['beta'], + dividend_yield=s['dividend_yield'], + quarterly_dividend=s['quarterly_dividend'], + ex_dividend_date=s['ex_dividend_date'], + after_price=s['after_price'], after_change=s['after_change'], + after_change_pct=s['after_change_pct'])) + return s + + +def _add_quote_simple(db, M, inst, closes, intraday): + s = MD.simple_stats(inst.slug, closes, intraday, inst.kind) + r = MD.rng_for(inst.slug, 'simple') + cap = None + if inst.kind == 'crypto': + cap = _anchor(inst.slug, 'mkt_cap') or s['price'] * r.uniform(2e7, 9e8) + db.session.add(M['Quote']( + instrument_id=inst.id, price=s['price'], prev_close=s['prev_close'], + change=s['change'], change_pct=s['change_pct'], + day_open=s['day_open'], day_high=s['day_high'], day_low=s['day_low'], + wk52_high=s['wk52_high'], wk52_low=s['wk52_low'], + volume=int(r.uniform(2e6, 9e9)) if inst.kind == 'crypto' else None, + mkt_cap=cap)) + return s + + +# --------------------------------------------------------------------------- +# Phase 1 — instruments, prices, fundamentals +# --------------------------------------------------------------------------- + +def seed_market(db, M): + if M['Instrument'].query.count() > 0: + return + + Instrument = M['Instrument'] + made = {} # slug -> Instrument + + # --- equities ----------------------------------------------------------- + equity_specs = [] + for sector, rows in STOCKS.items(): + for ticker, exch in rows: + equity_specs.append((ticker, exch, sector, 'stock')) + for ticker, exch in ETFS: + equity_specs.append((ticker, exch, 'ETF', 'etf')) + + for ticker, exch, sector, kind in equity_specs: + slug = f'{ticker}:{exch}' + meta = COMPANIES.get(slug, {}) + about = meta.get('about', {}) + inst = Instrument( + slug=slug, ticker=ticker, exchange=exch, + name=meta.get('name') or ticker, + kind=kind, sector=sector, + industry=about.get('Sector', ''), + currency='USD', region='us', + logo_file=meta.get('logo_file') or '', + profile=meta.get('profile', ''), + ceo=about.get('CEO', ''), + headquarters=about.get('Headquarters', ''), + founded=about.get('Founded', ''), + employees=about.get('Employees', ''), + website=about.get('Website', '') if about.get('Website') != '-' else '', + is_climate_leader=ticker in CLIMATE_LEADERS) + db.session.add(inst) + made[slug] = inst + db.session.flush() + + for ticker, exch, sector, kind in equity_specs: + inst = made[f'{ticker}:{exch}'] + closes, volumes, intraday = _make_series( + db, M, inst, kind, sector, _level_for(inst.slug, kind)) + s = _add_quote_full(db, M, inst, sector, closes, volumes, intraday) + inst.history.after_json = json.dumps( + MD.after_hours_path(inst.slug, s['price'], s['after_price'], + kind=kind)) + if kind == 'stock' and s['revenue_ttm']: + _seed_fundamentals(db, M, inst, s, closes) + + # --- indexes, sector indexes ------------------------------------------- + for ticker, exch, name, region in INDEXES: + slug = f'{ticker}:{exch}' + inst = Instrument(slug=slug, ticker=ticker, exchange=exch, name=name, + short_name=SHORT_NAMES.get(ticker, ''), + kind='index', region=region, currency='USD') + db.session.add(inst) + made[slug] = inst + for ticker, label, gics in SECTOR_INDEXES: + slug = f'{ticker}:INDEXCBOE' + inst = Instrument(slug=slug, ticker=ticker, exchange='INDEXCBOE', + name=f'S&P Select Sector {label} Index', + kind='sector', sector=gics, industry=label, + region='us', currency='USD') + db.session.add(inst) + made[slug] = inst + + # --- crypto, currencies, futures --------------------------------------- + for ticker, name in CRYPTO: + inst = Instrument(slug=ticker, ticker=ticker, exchange='', name=name, + short_name=SHORT_NAMES.get(ticker, name), + kind='crypto', region='crypto', currency='USD') + db.session.add(inst) + made[ticker] = inst + for ticker, name in CURRENCIES: + inst = Instrument(slug=ticker, ticker=ticker, exchange='', name=name, + short_name=ticker.replace('-', ' / '), + kind='currency', region='currencies', + currency=ticker.split('-')[1]) + db.session.add(inst) + made[ticker] = inst + for ticker, name in FUTURES: + inst = Instrument(slug=ticker, ticker=ticker, exchange='', name=name, + short_name=SHORT_NAMES.get(ticker, name), + kind='future', region='futures', currency='USD', + industry=FUTURE_UNITS.get(ticker, '')) + db.session.add(inst) + made[ticker] = inst + db.session.flush() + + for slug, inst in made.items(): + if inst.kind in ('stock', 'etf'): + continue + kind = inst.kind + closes, volumes, intraday = _make_series( + db, M, inst, kind, None, _level_for(slug, kind)) + _add_quote_simple(db, M, inst, closes, intraday) + + db.session.flush() + _seed_related(db, M, made) + _seed_etf_holdings(db, M, made) + db.session.commit() + print(f'[google_finance] seeded {len(made)} instruments') + + +def _seed_fundamentals(db, M, inst, stats, closes): + periods_q = MD.recent_quarters(8) + income = MD.income_statement(inst.slug, stats['revenue_ttm'], + stats['net_income_ttm'], + stats['shares_outstanding'], periods_q) + balance = MD.balance_sheet(inst.slug, stats['revenue_ttm'], periods_q) + cash = MD.cash_flow(inst.slug, stats['net_income_ttm'], periods_q) + + for name, rows in (('income', income), ('balance', balance), + ('cashflow', cash)): + for ordinal, row in enumerate(rows): + db.session.add(M['FinancialRow']( + instrument_id=inst.id, statement=name, period_type='quarterly', + period_label=row['period'], ordinal=ordinal, + items_json=json.dumps(row))) + + # Annual view: sum the four quarters of each calendar year we cover fully. + for name, rows in (('income', income), ('cashflow', cash)): + by_year = {} + for row in rows: + year = row['period'].split()[1] + by_year.setdefault(year, []).append(row) + ordinal = 0 + for year in sorted(by_year): + group = by_year[year] + if len(group) < 4: + continue + agg = {'period': year} + for k in group[0]: + if k == 'period': + continue + vals = [g[k] for g in group if isinstance(g[k], (int, float))] + if not vals: + continue + agg[k] = (sum(vals) if k not in + ('net_profit_margin', 'effective_tax_rate') + else sum(vals) / len(vals)) + db.session.add(M['FinancialRow']( + instrument_id=inst.id, statement=name, period_type='annual', + period_label=year, ordinal=ordinal, + items_json=json.dumps(agg))) + ordinal += 1 + + earn = MD.earnings_rows(inst.slug, income) + for m in MD.key_moments(inst.slug, closes, MD.CALENDAR, earn): + db.session.add(M['KeyMoment']( + instrument_id=inst.id, moment_date=m['date'], kind=m['kind'], + title=m['title'])) + + for row in earn: + db.session.add(M['EarningsRow']( + instrument_id=inst.id, quarter=row['quarter'], + report_date=row['report_date'], eps_estimate=row['eps_estimate'], + eps_actual=row['eps_actual'], + revenue_estimate=row['revenue_estimate'], + revenue_actual=row['revenue_actual'], + surprise_pct=row['surprise_pct'])) + + for h in MD.institutional_holders(inst.slug, stats['shares_outstanding'], + stats['price']): + db.session.add(M['InstitutionalHolder']( + instrument_id=inst.id, firm=h['firm'], pct_held=h['pct_held'], + shares=h['shares'], value=h['value'], change_pct=h['change_pct'])) + + for r in MD.analyst_coverage(inst.slug, stats['price']): + db.session.add(M['AnalystRating']( + instrument_id=inst.id, firm=r['firm'], rating=r['rating'], + price_target=r['price_target'], rated_on=r['rated_on'])) + + +def _seed_related(db, M, made): + """Related-instrument graph: harvested neighbours first, then same sector.""" + by_sector = {} + for slug, inst in made.items(): + if inst.kind == 'stock': + by_sector.setdefault(inst.sector, []).append(slug) + for k in by_sector: + by_sector[k].sort() + + for slug, inst in made.items(): + rel = [] + for cand in (COMPANIES.get(slug, {}).get('related') or []): + if cand in made and cand != slug and cand not in rel: + rel.append(cand) + if inst.kind == 'stock': + for cand in by_sector.get(inst.sector, []): + if len(rel) >= 6: + break + if cand != slug and cand not in rel: + rel.append(cand) + elif inst.kind in ('index', 'sector'): + peers = sorted(s for s, i in made.items() if i.kind == inst.kind) + for cand in peers: + if len(rel) >= 6: + break + if cand != slug and cand not in rel: + rel.append(cand) + elif inst.kind in ('crypto', 'currency', 'future', 'etf'): + peers = sorted(s for s, i in made.items() if i.kind == inst.kind) + for cand in peers: + if len(rel) >= 6: + break + if cand != slug and cand not in rel: + rel.append(cand) + inst.related_json = json.dumps(rel[:6]) + + +ETF_MANDATE = { + 'SPY': None, 'VOO': None, 'IVV': None, 'VTI': None, 'IWM': None, + 'QQQ': ['Technology', 'Communication Services', 'Consumer Discretionary'], + 'XLK': ['Technology'], + 'XLE': ['Energy'], + 'XLF': ['Financials'], + 'ARKK': ['Technology', 'Health Care', 'Consumer Discretionary'], + 'VIG': ['Consumer Staples', 'Health Care', 'Industrials', 'Financials'], + 'SCHD': ['Consumer Staples', 'Energy', 'Health Care', 'Financials'], +} + + +def _seed_etf_holdings(db, M, made): + stocks = [(s, i) for s, i in made.items() if i.kind == 'stock'] + for slug, inst in made.items(): + if inst.kind != 'etf': + continue + mandate = ETF_MANDATE.get(inst.ticker) + pool = [(s, i) for s, i in stocks + if mandate is None or i.sector in mandate] + pool.sort(key=lambda x: -(x[1].quote.mkt_cap or 0) if x[1].quote else 0) + picks = pool[:12] + if not picks: + continue + rng = MD.rng_for(slug, 'holdings') + raw = [max(0.4, rng.gauss(8, 4)) for _ in picks] + total = sum(raw) + for (hslug, hinst), w in zip(picks, raw): + db.session.add(M['EtfHolding']( + etf_id=inst.id, holding_id=hinst.id, + weight_pct=round(w / total * 100 * 0.72, 2))) + + +# --------------------------------------------------------------------------- +# Phase 2 — publishers, market summaries, news +# --------------------------------------------------------------------------- + +def seed_news(db, M): + if M['NewsArticle'].query.count() > 0: + return + + pubs = {} + for p in PUBLISHERS: + row = M['Publisher'](name=p['name'], domain=p['domain'], + favicon_file=p.get('file', '')) + db.session.add(row) + pubs[p['domain']] = row + db.session.flush() + pub_list = [pubs[p['domain']] for p in PUBLISHERS] + + for region, rows in MARKET_SUMMARIES.items(): + for rank, (headline, body, sources) in enumerate(rows): + db.session.add(M['MarketSummary']( + region=region, headline=headline, body=body, + sources_count=sources, rank=rank)) + + for i, (slug, headline, body, published, region) in enumerate( + build_market_news()): + db.session.add(M['NewsArticle']( + slug=slug, headline=headline, + publisher_id=pub_list[i % len(pub_list)].id, + published_at=published, summary=body.split('\n\n')[0], + body=body, scope='market', region=region)) + + instruments = (M['Instrument'].query + .filter(M['Instrument'].kind.in_(['stock', 'etf'])) + .order_by(M['Instrument'].slug).all()) + db.session.flush() + counter = 0 + for inst in instruments: + for slug, headline, summary, body, published in build_company_news( + inst.ticker, inst.name): + art = M['NewsArticle']( + slug=slug, headline=headline, + publisher_id=pub_list[counter % len(pub_list)].id, + published_at=published, summary=summary, body=body, + scope='company', region='us') + db.session.add(art) + db.session.flush() + db.session.add(M['NewsLink'](news_id=art.id, + instrument_id=inst.id)) + counter += 1 + + db.session.commit() + print(f'[google_finance] seeded {counter} company articles, ' + f'{len(PUBLISHERS)} publishers') + + +# --------------------------------------------------------------------------- +# Phase 3 — benchmark accounts +# --------------------------------------------------------------------------- + +PASSWORD = 'TestPass123!' +BENCHMARK_USERS = [ + {'email': 'alice.j@test.com', 'username': 'alice_j', 'name': 'Alice Johnson'}, + {'email': 'bob.c@test.com', 'username': 'bob_c', 'name': 'Bob Chen'}, + {'email': 'carol.d@test.com', 'username': 'carol_d', 'name': 'Carol Davis'}, + {'email': 'david.k@test.com', 'username': 'david_k', 'name': 'David Kim'}, +] + +# Alice keeps two lists and two portfolios so "remove X from my list" and +# "what is my portfolio worth" are genuine disambiguation tasks rather than +# single-row lookups. +ALICE_LISTS = { + 'Tech watch': ['AAPL:NASDAQ', 'MSFT:NASDAQ', 'NVDA:NASDAQ', + 'AMD:NASDAQ', 'CRM:NYSE'], + 'Dividend income': ['KO:NYSE', 'PG:NYSE', 'XOM:NYSE', 'DUK:NYSE', + 'O:NYSE', 'VZ:NYSE'], +} +ALICE_PORTFOLIOS = { + 'Retirement': (4820.55, [ + ('AAPL:NASDAQ', 120, 214.30, '2024-03-18'), + ('MSFT:NASDAQ', 65, 298.75, '2023-11-02'), + ('JPM:NYSE', 90, 168.40, '2024-07-09'), + ('JNJ:NYSE', 140, 152.15, '2023-05-22'), + ('VOO:NYSEARCA', 45, 402.60, '2022-09-14'), + ]), + 'Growth ideas': (1150.00, [ + ('NVDA:NASDAQ', 40, 96.85, '2024-01-25'), + ('TSLA:NASDAQ', 55, 242.10, '2025-02-11'), + ('ABNB:NASDAQ', 70, 131.45, '2024-10-30'), + ]), +} +# Bob, Carol and David each carry a themed list and one portfolio, so every +# benchmark account has pre-existing auth-gated state. Cost bases are set +# against the frozen MARKET_DATE prices to give each portfolio a spread of +# gains including one losing position. +BOB_LISTS = { + 'Media & platforms': ['GOOGL:NASDAQ', 'META:NASDAQ', 'NFLX:NASDAQ', + 'DIS:NYSE'], +} +BOB_PORTFOLIOS = { + 'Brokerage': (1540.60, [ + ('META:NASDAQ', 25, 372.80, '2023-09-27'), + ('DIS:NYSE', 110, 104.50, '2024-04-03'), + ('TMUS:NASDAQ', 40, 148.25, '2023-06-16'), + ('VTI:NYSEARCA', 35, 226.15, '2022-08-10'), + ]), +} +CAROL_LISTS = { + 'Healthcare': ['ABBV:NYSE', 'LLY:NYSE', 'MRK:NYSE', 'PFE:NYSE', + 'TMO:NYSE', 'UNH:NYSE'], +} +CAROL_PORTFOLIOS = { + 'Core holdings': (2310.40, [ + ('UNH:NYSE', 35, 486.20, '2024-06-12'), + ('LLY:NYSE', 20, 742.90, '2023-08-15'), + ('COST:NASDAQ', 15, 605.20, '2022-12-06'), + ('SPY:NYSEARCA', 30, 428.75, '2023-04-19'), + ]), +} +DAVID_LISTS = { + 'Semiconductors': ['AMAT:NASDAQ', 'AVGO:NASDAQ', 'LRCX:NASDAQ', + 'MU:NASDAQ', 'QCOM:NASDAQ', 'TXN:NASDAQ'], +} +DAVID_PORTFOLIOS = { + 'Long term': (745.80, [ + ('AMZN:NASDAQ', 50, 138.60, '2023-03-08'), + ('GOOGL:NASDAQ', 60, 121.35, '2022-11-21'), + ('QCOM:NASDAQ', 45, 182.90, '2025-01-14'), + ('QQQ:NASDAQ', 25, 355.90, '2023-01-31'), + ]), +} + + +def seed_benchmark_users(db, M, bcrypt): + if M['User'].query.filter_by(email='alice.j@test.com').first(): + return + + users = {} + for spec in BENCHMARK_USERS: + u = M['User'](email=spec['email'], username=spec['username'], + name=spec['name']) + u.password_hash = bcrypt.generate_password_hash( + PASSWORD).decode('utf-8') + db.session.add(u) + users[spec['email']] = u + db.session.flush() + + def inst_by(slug): + return M['Instrument'].query.filter_by(slug=slug).first() + + def add_lists(user, mapping): + for name, slugs in mapping.items(): + wl = M['Watchlist'](user_id=user.id, name=name) + db.session.add(wl) + db.session.flush() + for s in slugs: + inst = inst_by(s) + if inst: + db.session.add(M['WatchlistItem'](watchlist_id=wl.id, + instrument_id=inst.id)) + + def add_portfolios(user, mapping): + for name, (cash, lots) in mapping.items(): + p = M['Portfolio'](user_id=user.id, name=name, cash=cash) + db.session.add(p) + db.session.flush() + for slug, shares, cost, bought in lots: + inst = inst_by(slug) + if inst: + db.session.add(M['PortfolioLot']( + portfolio_id=p.id, instrument_id=inst.id, + shares=shares, cost_basis=cost, purchased_on=bought)) + + # Order matters: watchlist ids are assigned in insertion order and the + # robustness suite reads /lists/3 as "a list Alice does not own". + add_lists(users['alice.j@test.com'], ALICE_LISTS) + add_portfolios(users['alice.j@test.com'], ALICE_PORTFOLIOS) + add_lists(users['bob.c@test.com'], BOB_LISTS) + add_portfolios(users['bob.c@test.com'], BOB_PORTFOLIOS) + add_lists(users['carol.d@test.com'], CAROL_LISTS) + add_portfolios(users['carol.d@test.com'], CAROL_PORTFOLIOS) + add_lists(users['david.k@test.com'], DAVID_LISTS) + add_portfolios(users['david.k@test.com'], DAVID_PORTFOLIOS) + + db.session.commit() + print(f'[google_finance] seeded {len(BENCHMARK_USERS)} benchmark users') diff --git a/sites/google_finance/static/css/style.css b/sites/google_finance/static/css/style.css new file mode 100644 index 00000000..8e396cef --- /dev/null +++ b/sites/google_finance/static/css/style.css @@ -0,0 +1,817 @@ +/* Google Finance mirror — layout and typography. + Colour and type values follow the live site: Google Sans / Roboto stack, + #137333 for gains, #a50e0e for losses, #1a73e8 for interactive text. */ + +:root { + --blue: #1a73e8; + /* Gains and losses are named by direction, not by colour, so the + up/down convention can be swapped without touching any rule. */ + --green: #137333; + --red: #a50e0e; + --green-bg: #e6f4ea; + --red-bg: #fce8e6; + --up: var(--green); + --down: var(--red); + --up-bg: var(--green-bg); + --down-bg: var(--red-bg); + --crosshair-dot: var(--text); + --text: #1f1f1f; + --text-2: #5f6368; + --text-3: #80868b; + --line: #dadce0; + --line-soft: #e8eaed; + --surface: #ffffff; + --surface-2: #f8f9fa; + --surface-3: #f1f3f4; + --radius: 16px; + --rail-l: 300px; + --rail-r: 320px; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--surface); + color: var(--text); + font-family: "Google Sans", "Google Sans Text", Roboto, -apple-system, + "Helvetica Neue", Arial, sans-serif; + font-size: 14px; + line-height: 1.45; + -webkit-font-smoothing: antialiased; +} + +a { color: inherit; text-decoration: none; } +a:hover { text-decoration: none; } + +.sr-only { + position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; + overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; +} + +/* ---------------------------------------------------------------- header */ + +.gf-header { + display: grid; + grid-template-columns: var(--rail-l) minmax(0, 1fr) auto; + align-items: center; + gap: 16px; + padding: 10px 20px; + background: var(--surface); +} + +.gf-brand { display: flex; align-items: baseline; gap: 8px; } +.gf-wordmark { font-size: 25px; letter-spacing: -.5px; font-weight: 500; } +.g-b { color: #4285f4; } .g-r { color: #ea4335; } +.g-y { color: #fbbc04; } .g-g { color: #34a853; } +.gf-brand-sub { font-size: 22px; color: #5f6368; font-weight: 400; } + +.gf-search { + position: relative; + display: flex; + align-items: center; + gap: 10px; + max-width: 700px; + width: 100%; + justify-self: center; + background: var(--surface-3); + border-radius: 999px; + padding: 0 18px; + height: 48px; +} +.gf-search:focus-within { background: #fff; box-shadow: 0 1px 6px rgba(32,33,36,.28); } +.gf-search-icon { width: 20px; height: 20px; fill: var(--text-2); flex: none; } +.gf-search input { + border: 0; background: transparent; outline: none; flex: 1; + font-size: 15px; font-family: inherit; color: var(--text); +} +.gf-search input::placeholder { color: var(--text-3); } +.gf-search-go { + border: 0; background: transparent; color: var(--blue); + font: inherit; font-size: 13px; cursor: pointer; padding: 4px 6px; +} +.gf-search-go:hover { text-decoration: underline; } + +.gf-suggest { + position: absolute; top: 52px; left: 0; right: 0; z-index: 40; + margin: 0; padding: 6px 0; list-style: none; + background: #fff; border-radius: 12px; + box-shadow: 0 4px 14px rgba(32,33,36,.28); + max-height: 380px; overflow-y: auto; +} +.gf-suggest li a { + display: flex; align-items: center; gap: 12px; padding: 9px 18px; +} +.gf-suggest li a:hover { background: var(--surface-3); } +.gf-suggest .s-tic { font-weight: 500; min-width: 68px; } +.gf-suggest .s-name { color: var(--text-2); flex: 1; overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } + +.gf-header-actions { display: flex; align-items: center; gap: 6px; } +.gf-icon-link { + width: 40px; height: 40px; border-radius: 50%; display: grid; + place-items: center; +} +.gf-icon-link:hover { background: var(--surface-3); } +.gf-icon-link svg { width: 22px; height: 22px; fill: var(--text-2); } +.gf-account { padding: 8px 12px; color: var(--text-2); } +.gf-signin { + background: var(--blue); color: #fff; padding: 9px 22px; + border-radius: 999px; font-weight: 500; font-size: 14px; +} +.gf-signin:hover { background: #1b66c9; } + +/* ----------------------------------------------------------------- shell */ + +.gf-shell { + display: grid; + grid-template-columns: var(--rail-l) minmax(0, 1fr) var(--rail-r); + gap: 0; + align-items: start; + padding: 0 20px 40px; +} + +.gf-rail { padding: 12px 0 24px; } +.gf-rail-left { border-right: 1px solid var(--line-soft); padding-right: 22px; } +.gf-rail-right { border-left: 1px solid var(--line-soft); padding-left: 22px; } +.gf-main { padding: 12px 26px 24px; min-width: 0; } + +/* ------------------------------------------------------------- left rail */ + +.rail-block { margin-bottom: 26px; } +.rail-head { display: flex; align-items: center; justify-content: space-between; + margin-bottom: 10px; } +.rail-title { font-size: 20px; font-weight: 400; color: var(--text); } +a.rail-title:hover { color: var(--blue); } +.rail-list { list-style: none; margin: 0; padding: 0; } +.rail-list li a { + display: flex; justify-content: space-between; padding: 8px 12px; + border-radius: 8px; color: var(--text); font-size: 14px; +} +.rail-list li a:hover { background: var(--surface-3); } +.rail-count { color: var(--text-3); } +.rail-empty { color: var(--text-2); font-size: 13px; margin: 4px 0 10px; } +.rail-empty a { color: var(--blue); } +.rail-cta { + display: inline-flex; align-items: center; gap: 8px; margin-top: 8px; + border: 1px solid var(--line); border-radius: 999px; padding: 9px 20px; + color: var(--text); font-size: 14px; +} +.rail-cta:hover { background: var(--surface-3); } +.rail-cta-plus { font-size: 17px; line-height: 1; color: var(--text-2); } + +.sector-list { list-style: none; margin: 0; padding: 0; } +.sector-list li a { + display: grid; grid-template-columns: 1fr auto auto; gap: 10px; + align-items: center; padding: 7px 8px; border-radius: 8px; +} +.sector-list li a:hover { background: var(--surface-3); } +.sector-name { display: flex; flex-direction: column; min-width: 0; } +.sector-ticker { font-size: 13px; font-weight: 500; } +.sector-label { font-size: 12px; color: var(--text-2); } +.sector-vals { display: flex; flex-direction: column; align-items: flex-end; } +.sector-price { font-size: 13px; } + +/* ------------------------------------------------------------ right rail */ + +.rail-panel-title { font-size: 22px; font-weight: 400; margin: 4px 0 18px; } +.rail-sub { font-size: 15px; font-weight: 500; margin: 22px 0 8px; } +.rail-mini { list-style: none; margin: 0; padding: 0; } +.rail-mini li a { + display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: center; + padding: 8px; border-radius: 8px; +} +.rail-mini li a:hover { background: var(--surface-3); } +.rail-mini .m-tic { font-size: 13px; font-weight: 500; } +.rail-mini .m-name { font-size: 12px; color: var(--text-2); + display: block; max-width: 150px; overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } +.rail-news { list-style: none; margin: 0; padding: 0; } +.rail-news li { padding: 10px 0; border-bottom: 1px solid var(--line-soft); } +.rail-news li:last-child { border-bottom: 0; } +.rail-news a { font-size: 13px; } +.rail-news a:hover { color: var(--blue); } +.rail-news .meta { display: block; font-size: 11px; color: var(--text-2); + margin-top: 4px; } + +/* ------------------------------------------------------------- home tabs */ + +.region-tabs { + display: flex; gap: 6px; margin: 4px 0 20px; flex-wrap: wrap; + border-bottom: 1px solid var(--line-soft); padding-bottom: 12px; +} +.region-tabs a { + padding: 8px 18px; border-radius: 999px; font-size: 14px; color: var(--text-2); +} +.region-tabs a:hover { background: var(--surface-3); } +.region-tabs a.on { background: var(--surface-3); color: var(--text); + border: 1px solid var(--line); } + +/* One row whatever the count. A fixed 5-column track orphaned the sixth card + of the Currencies / Crypto / Futures tabs onto a second row beside four + empty cells; upstream lays these out as a single strip. */ +.index-cards { + display: grid; grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); + gap: 12px; margin-bottom: 28px; +} +.index-card { + border: 1px solid var(--line-soft); border-radius: 12px; + padding: 12px 14px 0; overflow: hidden; display: flex; flex-direction: column; +} +.index-card:hover { box-shadow: 0 1px 6px rgba(32,33,36,.16); } +.ic-name { font-size: 14px; font-weight: 500; margin-bottom: 4px; } +.ic-price { font-size: 13px; color: var(--text-2); } +.ic-abs { font-size: 12px; color: var(--text-3); } +.ic-chg { font-size: 15px; font-weight: 500; margin: 6px 0 8px; } +.ic-spark { margin: 0 -14px -1px; line-height: 0; } +.ic-spark svg { width: 100%; height: 60px; display: block; } + +/* --------------------------------------------------------- market blocks */ + +.section-title { font-size: 22px; font-weight: 400; margin: 26px 0 12px; } +.subsection-title { font-size: 16px; font-weight: 500; margin: 22px 0 8px; } + +.summary-item { border-bottom: 1px solid var(--line-soft); padding: 16px 0; } +.summary-item:first-of-type { padding-top: 8px; } +.summary-head { display: flex; justify-content: space-between; gap: 16px; + align-items: flex-start; } +.summary-head h3 { font-size: 17px; font-weight: 400; margin: 0; } +.summary-sources { font-size: 12px; color: var(--text-2); white-space: nowrap; } +.summary-body { color: var(--text); margin: 12px 0 0; max-width: 68ch; } +.summary-body p { margin: 0 0 12px; } + +.updates { list-style: none; margin: 0; padding: 0; } +.updates li { + display: grid; grid-template-columns: 92px 150px 1fr; gap: 12px; + align-items: center; padding: 9px 0; border-bottom: 1px solid var(--line-soft); + font-size: 13px; +} +.updates .u-time { color: var(--text-2); } +.updates .u-pub { display: flex; align-items: center; gap: 7px; + color: var(--text-2); overflow: hidden; } +.updates .u-pub img { width: 16px; height: 16px; border-radius: 50%; } +.updates .u-pub span { overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; } +.updates a:hover { color: var(--blue); } + +/* ------------------------------------------------------------- data table */ + +/* Wide tables (portfolio positions, compare, financials) scroll inside their + own box rather than pushing the three-column shell out of alignment. */ +.table-wrap { overflow-x: auto; max-width: 100%; } +.q-table { width: 100%; border-collapse: collapse; } +.q-table th { + text-align: right; font-weight: 500; font-size: 12px; color: var(--text-2); + padding: 10px 12px; border-bottom: 1px solid var(--line); +} +.q-table th:first-child, .q-table td:first-child { text-align: left; } +.q-table td { padding: 12px; border-bottom: 1px solid var(--line-soft); + text-align: right; font-size: 14px; } +.q-table tbody tr:hover { background: var(--surface-2); } +.q-table .sym { display: flex; align-items: center; gap: 12px; } +.q-table .sym img { width: 26px; height: 26px; border-radius: 50%; + object-fit: contain; background: #fff; } +.q-table .sym .ph { + width: 26px; height: 26px; border-radius: 50%; background: var(--surface-3); + display: grid; place-items: center; font-size: 11px; color: var(--text-2); +} +.q-table .sym-t { font-weight: 500; } +.q-table .sym-n { font-size: 12px; color: var(--text-2); display: inline-block; + max-width: 190px; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; vertical-align: bottom; } +.q-table th, .q-table td { white-space: nowrap; } +.q-table .spark { vertical-align: middle; } + +.chg { font-weight: 500; } +.chg.up, .up { color: var(--up); } +.chg.down, .down { color: var(--down); } +.pill { display: inline-block; padding: 2px 8px; border-radius: 999px; + font-size: 12px; font-weight: 500; } +.pill.up { background: var(--up-bg); color: var(--up); } +.pill.down { background: var(--down-bg); color: var(--down); } +/* The portfolio lots table is the widest in the site: 8 columns of figures + that reach six digits. Left alone it needs ~960px inside a 728px column, + pushing Gain % out of view. Three savings bring it under: the percentage + sits below the amount rather than beside it, cells are padded 8px instead + of 12px, and the company name truncates earlier than elsewhere. */ +.gain-cell .pill { display: block; margin-left: 0; } +.pf-table th, .pf-table td { padding-left: 8px; padding-right: 8px; } +.pf-table .sym-n { max-width: 90px; } + +/* ------------------------------------------------------------ quote page */ + +.crumbs { display: flex; align-items: center; gap: 10px; font-size: 13px; + color: var(--text-2); margin-bottom: 14px; } +.crumbs a { color: var(--text-2); } +.crumbs a:hover { color: var(--blue); } + +.q-head { display: flex; align-items: flex-start; gap: 14px; + justify-content: space-between; } +.q-ident { display: flex; align-items: center; gap: 12px; } +.q-ident img { width: 34px; height: 34px; border-radius: 50%; + object-fit: contain; } +.q-ident .ph { width: 34px; height: 34px; border-radius: 50%; + background: var(--surface-3); display: grid; place-items: center; + font-size: 12px; color: var(--text-2); } +.q-name { font-size: 22px; font-weight: 400; } + +.q-price-row { display: flex; align-items: baseline; gap: 14px; + flex-wrap: wrap; margin: 14px 0 2px; } +.q-price { font-size: 34px; font-weight: 400; letter-spacing: -.5px; } +.q-move { font-size: 17px; font-weight: 500; } +.q-move .word { color: var(--text-2); font-weight: 400; } +.q-after { display: flex; align-items: baseline; gap: 10px; + padding-left: 18px; border-left: 1px solid var(--line-soft); } +.q-after .ap { font-size: 17px; } +.q-after .am { font-size: 14px; font-weight: 500; } +.q-stamp { font-size: 12px; color: var(--text-2); margin-bottom: 16px; } +.q-stamp .dot { margin: 0 6px; } + +.btn-outline { + border: 1px solid var(--line); border-radius: 999px; padding: 9px 20px; + background: #fff; font: inherit; font-size: 14px; cursor: pointer; + display: inline-flex; align-items: center; gap: 8px; color: var(--text); +} +.btn-outline:hover { background: var(--surface-3); } +.btn-blue { + background: #d3e3fd; border: 0; border-radius: 999px; padding: 10px 20px; + font: inherit; font-size: 14px; color: #041e49; cursor: pointer; + display: inline-flex; align-items: center; gap: 8px; +} +.btn-blue:hover { background: #c2d9fb; } +.btn-text { background: none; border: 0; color: var(--blue); font: inherit; + cursor: pointer; padding: 4px 6px; } +.btn-text:hover { text-decoration: underline; } + +.chart-wrap { border: 1px solid var(--line-soft); border-radius: 12px; + overflow: hidden; } +.chart-toolbar { display: flex; gap: 8px; padding: 10px 14px; + border-bottom: 1px solid var(--line-soft); background: var(--surface-2); } +.chart-toolbar span { font-size: 13px; color: var(--text-2); + padding: 4px 10px; border-radius: 999px; background: #fff; + border: 1px solid var(--line-soft); } +.chart-host { position: relative; background: #fff; } +.price-chart { display: block; width: 100%; height: auto; background: #fff; } +.chart-empty { padding: 60px 0; text-align: center; color: var(--text-2); } +.price-chart .grid { stroke: var(--line-soft); stroke-width: 1; } +.price-chart .vgrid { stroke: #eef0f2; stroke-width: 1; } +.price-chart .baseline { stroke: var(--text-3); stroke-width: 1; + stroke-dasharray: 2 4; } +.price-chart .session-split { stroke: var(--line); stroke-width: 1; } +.price-chart .after-line { stroke: #9aa0a6; stroke-width: 1.6; fill: none; } +.price-chart .series-line { stroke-width: 1.7; stroke-linejoin: round; + stroke-linecap: round; } +.price-chart .series-line.up { stroke: var(--up); } +.price-chart .series-line.down { stroke: var(--down); } +.price-chart .end-dot.up { fill: var(--up); } +.price-chart .end-dot.down { fill: var(--down); } +.price-chart .end-dot.after { fill: var(--text-3); } +.price-chart .g0.up { stop-color: var(--up); stop-opacity: .34; } +.price-chart .g1.up { stop-color: var(--up); stop-opacity: .01; } +.price-chart .g0.down { stop-color: var(--down); stop-opacity: .30; } +.price-chart .g1.down { stop-color: var(--down); stop-opacity: .01; } +.price-chart .vol { opacity: .45; } +.price-chart .vol.vu { fill: var(--up); } +.price-chart .vol.vd { fill: var(--down); } +.ytick, .xtick { font-size: 13px; fill: var(--text-2); + font-family: Roboto, Arial, sans-serif; } +.ytick.prev { fill: var(--text-2); font-size: 11px; } + +/* hover crosshair, mirroring the read-out Google shows on the live chart */ +.price-chart .crosshair .cross-line { stroke: var(--text-3); stroke-width: 1; + stroke-dasharray: 3 3; } +.price-chart .crosshair .cross-dot { fill: var(--crosshair-dot); + stroke: none; } +.chart-readout { + position: absolute; top: 8px; left: 10px; z-index: 3; + background: #fff; border: 1px solid var(--line); border-radius: 8px; + padding: 6px 12px; font-size: 12px; white-space: nowrap; + box-shadow: 0 1px 4px rgba(32,33,36,.2); pointer-events: none; +} +.chart-readout .k { color: var(--text-2); margin-right: 4px; } +.chart-readout .v { font-weight: 500; } +.chart-readout .sep { display: inline-block; width: 14px; } +.chart-stamp { + position: absolute; bottom: 26px; z-index: 3; transform: translateX(-50%); + background: var(--surface-3); border-radius: 6px; padding: 4px 10px; + font-size: 12px; color: var(--text); white-space: nowrap; + pointer-events: none; +} + +.range-tabs { display: flex; gap: 2px; padding: 8px 10px; + border-top: 1px solid var(--line-soft); background: #fff; } +.range-tabs a { padding: 6px 14px; border-radius: 999px; font-size: 13px; + color: var(--text-2); } +.range-tabs a:hover { background: var(--surface-3); } +.range-tabs a.on { background: var(--surface-3); color: var(--text); + font-weight: 500; } +.range-note { font-size: 13px; color: var(--text-2); padding: 0 10px 10px; } + +.q-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line-soft); + margin: 22px 0 18px; } +.q-tabs a { padding: 12px 18px; font-size: 14px; color: var(--text-2); + border-bottom: 3px solid transparent; } +.q-tabs a:hover { color: var(--text); } +.q-tabs a.on { color: var(--blue); border-bottom-color: var(--blue); + font-weight: 500; } + +.stats-grid { + display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0 34px; +} +.stats-grid > div + div { border-left: 1px solid var(--line-soft); + padding-left: 34px; margin-left: -17px; } +.stat-row { display: flex; justify-content: space-between; gap: 12px; + padding: 11px 0; border-bottom: 1px solid var(--line-soft); font-size: 14px; } +.stat-row dt { color: var(--text-2); margin: 0; } +.stat-row dd { margin: 0; text-align: right; } + +.cards-row { display: grid; + grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); gap: 12px; } +.mini-card { border: 1px solid var(--line-soft); border-radius: 12px; + padding: 12px 14px 0; overflow: hidden; } +.mini-card:hover { box-shadow: 0 1px 6px rgba(32,33,36,.16); } +.mc-tic { font-size: 12px; color: var(--text-2); } +.mc-name { font-size: 14px; font-weight: 500; margin: 2px 0 6px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.mc-price { font-size: 13px; color: var(--text-2); } +.mc-chg { font-size: 14px; font-weight: 500; margin: 4px 0 8px; } +.mc-spark { margin: 0 -14px -1px; line-height: 0; } +.mc-spark svg { width: 100%; height: 54px; display: block; } + +.news-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 26px; } +.news-item { padding: 14px 0; border-bottom: 1px solid var(--line-soft); } +.news-meta { display: flex; align-items: center; gap: 8px; font-size: 12px; + color: var(--text-2); margin-bottom: 6px; } +.news-meta img { width: 18px; height: 18px; border-radius: 50%; } +.news-head { font-size: 15px; line-height: 1.35; } +.news-head:hover { color: var(--blue); } + +.profile-text { max-width: 70ch; color: var(--text); } +.profile-text p { margin: 0 0 12px; } +.about-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 40px; } + +/* ------------------------------------------------------------ financials */ + +.fin-tabs { display: flex; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; } +.fin-tabs a { padding: 8px 16px; border-radius: 999px; font-size: 13px; + border: 1px solid var(--line); color: var(--text-2); } +.fin-tabs a.on { background: var(--surface-3); color: var(--text); + font-weight: 500; } +.bar-legend { display: flex; gap: 18px; font-size: 12px; color: var(--text-2); + margin: 6px 0 4px; } +.bar-legend .lg { display: inline-flex; align-items: center; gap: 6px; } +.bar-legend i { width: 10px; height: 10px; border-radius: 2px; display: block; } +.bar-chart { display: block; background: var(--surface-2); + border-radius: 12px; padding: 8px 0; } +.bar-chart .grid { stroke: #dfe3e6; stroke-width: 1; } +.fin-note { font-size: 12px; color: var(--text-2); margin: 10px 0; } + +/* --------------------------------------------------------------- consensus */ + +.consensus { display: grid; grid-template-columns: 190px 1fr; gap: 28px; + align-items: center; border: 1px solid var(--line-soft); + border-radius: 12px; padding: 18px; margin-bottom: 20px; } +.consensus-label { font-size: 26px; font-weight: 400; } +.consensus-sub { font-size: 13px; color: var(--text-2); } +.rating-bars { display: grid; gap: 6px; } +.rating-bar { display: grid; grid-template-columns: 90px 1fr 28px; gap: 10px; + align-items: center; font-size: 13px; } +.rating-bar .track { background: var(--surface-3); border-radius: 4px; + height: 10px; overflow: hidden; } +.rating-bar .fill { background: var(--blue); height: 100%; display: block; } + +/* -------------------------------------------------------------- forms etc */ + +.panel { border: 1px solid var(--line-soft); border-radius: 12px; + padding: 20px; margin-bottom: 20px; } +.panel h2 { font-size: 17px; font-weight: 500; margin: 0 0 14px; } + +.form-row { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; } +.form-row label { font-size: 13px; color: var(--text-2); } +.form-row input, .form-row select { + border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; + font: inherit; font-size: 14px; background: #fff; color: var(--text); +} +.form-row input:focus, .form-row select:focus { outline: 2px solid var(--blue); + outline-offset: -2px; } +.form-inline { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end; } +.form-inline .form-row { margin-bottom: 0; } +.form-error { color: var(--down); font-size: 12px; } +.auth-wrap { max-width: 420px; margin: 30px auto; } +.auth-wrap h1 { font-size: 24px; font-weight: 400; margin-bottom: 6px; } +.auth-hint { background: var(--surface-2); border-radius: 12px; padding: 14px; + font-size: 13px; color: var(--text-2); margin-bottom: 18px; } +.auth-hint code { background: #fff; padding: 1px 5px; border-radius: 4px; + border: 1px solid var(--line-soft); } + +.flashes { margin-bottom: 14px; } +.flash { padding: 11px 14px; border-radius: 8px; font-size: 13px; + margin-bottom: 8px; } +.flash-success { background: var(--up-bg); color: #0d652d; } +.flash-error { background: var(--down-bg); color: #b3261e; } +.flash-info { background: #e8f0fe; color: #174ea6; } + +.empty-state { color: var(--text-2); padding: 24px 0; } + +/* ---------------------------------------------------------------- footer */ + +.gf-footer { border-top: 1px solid var(--line-soft); margin-top: 20px; } +.gf-footer-inner { display: flex; justify-content: space-between; gap: 20px; + flex-wrap: wrap; padding: 16px 20px; font-size: 12px; color: var(--text-2); } +.gf-footer nav { display: flex; gap: 18px; flex-wrap: wrap; } +.gf-footer a:hover { color: var(--blue); } + +@media (max-width: 1200px) { + .gf-shell, .gf-header { grid-template-columns: 240px minmax(0,1fr); } + .gf-rail-right { display: none; } + .news-grid { grid-template-columns: 1fr; } + .stats-grid { grid-template-columns: repeat(2, minmax(0,1fr)); } +} + +/* ------------------------------------------------ account chooser + SSO */ + +.acct-card { + max-width: 450px; margin: 26px auto; border: 1px solid var(--line); + border-radius: 28px; padding: 44px 40px 32px; +} +.acct-head { text-align: left; margin-bottom: 26px; } +.acct-head .gf-wordmark { font-size: 24px; display: block; margin-bottom: 16px; } +.acct-head h1 { font-size: 24px; font-weight: 400; margin: 0 0 6px; } +.acct-head p { margin: 0; color: var(--text-2); font-size: 14px; } + +.acct-list { list-style: none; margin: 0; padding: 0; + border-top: 1px solid var(--line-soft); } +.acct-list li { border-bottom: 1px solid var(--line-soft); } +.acct-list form { margin: 0; } +.acct-row { + display: flex; align-items: center; gap: 16px; width: 100%; + padding: 14px 8px; background: none; border: 0; cursor: pointer; + font: inherit; text-align: left; color: var(--text); +} +.acct-row:hover { background: var(--surface-2); } +.acct-avatar { + width: 34px; height: 34px; border-radius: 50%; flex: none; + display: grid; place-items: center; color: #fff; font-size: 15px; + font-weight: 500; +} +.acct-avatar.acct-other { background: var(--surface-3); } +.acct-avatar.acct-other svg { width: 22px; height: 22px; fill: var(--text-2); } +.acct-who { display: flex; flex-direction: column; min-width: 0; } +.acct-name { font-size: 14px; } +.acct-email { font-size: 13px; color: var(--text-2); } +.acct-note { font-size: 12px; color: var(--text-2); margin: 26px 0 0; } +.acct-note a { color: var(--blue); } + +.google-btn { + display: flex; align-items: center; justify-content: center; gap: 12px; + width: 100%; border: 1px solid var(--line); border-radius: 999px; + padding: 12px 20px; font-size: 14px; font-weight: 500; color: #3c4043; + background: #fff; +} +.google-btn:hover { background: var(--surface-2); + box-shadow: 0 1px 3px rgba(60,64,67,.25); } +.google-btn-mark { display: grid; place-items: center; } +.google-btn-mark svg { width: 18px; height: 18px; display: block; } + +.auth-sep { display: flex; align-items: center; gap: 12px; margin: 18px 0; + color: var(--text-3); font-size: 12px; } +.auth-sep::before, .auth-sep::after { + content: ""; flex: 1; height: 1px; background: var(--line-soft); } + +/* standalone auth page (account chooser) */ +.auth-page { display: flex; flex-direction: column; min-height: 100vh; + background: #fff; } +.auth-page-main { flex: 1; display: flex; align-items: center; + justify-content: center; padding: 24px 16px; } +.auth-flashes { position: fixed; top: 16px; left: 50%; + transform: translateX(-50%); z-index: 5; min-width: 320px; } +.auth-page-footer { display: flex; justify-content: space-between; + align-items: center; gap: 20px; padding: 18px 28px; font-size: 12px; + color: var(--text-2); } +.auth-page-footer nav { display: flex; gap: 22px; } +.auth-page-footer a:hover { text-decoration: underline; } +.auth-page .acct-card { margin: 0; } + +/* --------------------------------------------- chart toolbar & key moments */ + +.chart-toolbar { gap: 6px; } +.chart-toolbar .tb-item { + display: inline-flex; align-items: center; gap: 7px; + font-size: 13px; color: var(--text); padding: 6px 12px; + border-radius: 999px; background: #fff; border: 1px solid var(--line-soft); +} +.chart-toolbar .tb-item svg { width: 17px; height: 17px; fill: var(--text-2); } +.chart-toolbar .caret { font-size: 10px; color: var(--text-2); font-weight: 400; } +.chart-toolbar .tb-moments.on { background: #fef7e0; border-color: #feefc3; } +.chart-toolbar .moment-swatch { width: 9px; height: 9px; border-radius: 50%; + background: #e8a33d; display: block; } +.chart-toolbar .tb-count { color: var(--text-2); } + +.price-chart .moment-halo { fill: #e8a33d; opacity: .18; } +.price-chart .moment-dot { fill: #e8a33d; } +.price-chart .moment { cursor: help; } + +.range-tabs a { position: relative; } +.range-tabs a[data-moments]::after { + content: ""; position: absolute; top: 2px; right: 6px; + width: 5px; height: 5px; border-radius: 50%; background: #e8a33d; +} + +.arrow-badge { + display: inline-grid; place-items: center; width: 18px; height: 18px; + border-radius: 50%; font-size: 11px; line-height: 1; vertical-align: -3px; + margin-right: 2px; +} +.arrow-badge.up { background: var(--up-bg); color: var(--up); } +.arrow-badge.down { background: var(--down-bg); color: var(--down); } + +/* index cards: typography closer to the live layout, dotted previous close */ +.spark-base { stroke: var(--text-3); stroke-width: 1; stroke-dasharray: 2 3; + vector-effect: non-scaling-stroke; opacity: .8; } +.ic-name { font-size: 16px; font-weight: 500; margin-bottom: 6px; + letter-spacing: -.1px; } +.ic-price { font-size: 14px; color: var(--text-2); line-height: 1.3; } +.ic-abs { font-size: 13px; color: var(--text-3); line-height: 1.3; } +.ic-chg { font-size: 17px; font-weight: 500; margin: 8px 0 10px; + display: flex; align-items: center; gap: 7px; } +.mc-chg { display: flex; align-items: center; gap: 6px; } + +/* market-summary accordion, matching the live "N sites" + collapse control */ +details.summary-item { border-bottom: 1px solid var(--line-soft); padding: 16px 0; } +details.summary-item > summary { list-style: none; cursor: pointer; } +details.summary-item > summary::-webkit-details-marker { display: none; } +.summary-meta { display: inline-flex; align-items: center; gap: 10px; + white-space: nowrap; } +.source-stack { display: inline-flex; align-items: center; } +.source-stack img { width: 20px; height: 20px; border-radius: 50%; + border: 2px solid #fff; margin-left: -7px; background: #fff; } +.source-stack img:first-child { margin-left: 0; } +.summary-chevron { width: 32px; height: 32px; border-radius: 50%; + display: grid; place-items: center; } +.summary-chevron svg { width: 20px; height: 20px; fill: var(--text-2); + transition: transform .15s ease; } +details.summary-item:not([open]) .summary-chevron svg { transform: rotate(180deg); } +.summary-chevron:hover { background: var(--surface-3); } + +/* left-rail header actions, as on the live "Lists" row */ +.rail-caret { font-size: 11px; color: var(--text-2); margin-left: 2px; } +.rail-actions { display: inline-flex; align-items: center; gap: 2px; } +.rail-icon { width: 32px; height: 32px; border-radius: 50%; display: grid; + place-items: center; } +.rail-icon:hover { background: var(--surface-3); } +.rail-icon svg { width: 19px; height: 19px; fill: var(--text-2); } + +/* chart toolbar menus (details/summary, so they work without JavaScript) */ +.tb-menu { position: relative; } +.tb-menu > summary { list-style: none; cursor: pointer; } +.tb-menu > summary::-webkit-details-marker { display: none; } +.tb-menu > summary:hover { background: var(--surface-3); } +.tb-item.on { background: #e8f0fe; border-color: #d2e3fc; } +.tb-pop { + position: absolute; top: 38px; left: 0; z-index: 30; min-width: 224px; + background: #fff; border-radius: 10px; padding: 6px 0; + box-shadow: 0 4px 14px rgba(32,33,36,.28); +} +.tb-pop a { display: block; padding: 9px 16px; font-size: 13px; } +.tb-pop a:hover { background: var(--surface-3); } +.tb-pop a.on { font-weight: 500; color: var(--blue); } +.ma-legend { display: flex; gap: 18px; padding: 8px 14px 0; font-size: 12px; + color: var(--text-2); } +.ma-legend span { display: inline-flex; align-items: center; gap: 6px; } +.ma-legend i { width: 14px; height: 0; border-top: 2px dashed currentColor; + display: block; } +.price-chart .ma-line { opacity: .9; } + +/* quote-page "Add to list" pill and its menu */ +.add-menu { position: relative; } +.add-menu > summary { list-style: none; cursor: pointer; } +.add-menu > summary::-webkit-details-marker { display: none; } +.add-pill { display: inline-flex; align-items: center; gap: 8px; } +.add-pill .tick { font-size: 13px; } +.add-pill .caret { font-size: 10px; opacity: .7; font-weight: 400; } +.add-pill.added { background: #c2e7ff; } +.add-pop { + position: absolute; right: 0; top: 46px; z-index: 30; min-width: 250px; + background: #fff; border-radius: 10px; padding: 6px 0; + box-shadow: 0 4px 14px rgba(32,33,36,.28); +} +.add-pop form { margin: 0; } +.add-row { + display: flex; align-items: center; gap: 12px; width: 100%; + padding: 10px 16px; background: none; border: 0; cursor: pointer; + font: inherit; font-size: 13px; text-align: left; color: var(--text); +} +.add-row:hover { background: var(--surface-3); } +.add-row .add-check { width: 14px; color: var(--blue); } +.add-row .add-count { margin-left: auto; color: var(--text-3); } +.add-row.on { font-weight: 500; } +.add-new { color: var(--blue); } + + +/* ===================================================================== + Theme and up/down colour convention. + + Both are set on from a cookie, server-side, so the first paint is + already correct — a client-side toggle would flash the wrong theme. Every + rule reads the variables, so neither switch needs its own stylesheet. + ===================================================================== */ + +/* "International" convention: red marks a gain, green a loss. Standard in + mainland China, Japan and Korea, and offered upstream as a setting. */ +html[data-colors="intl"] { + --up: var(--red); + --down: var(--green); + --up-bg: var(--red-bg); + --down-bg: var(--green-bg); +} + +html[data-theme="dark"] { + --blue: #8ab4f8; + --green: #81c995; + --red: #f28b82; + --green-bg: #14342a; + --red-bg: #3d2422; + --text: #e8eaed; + --text-2: #9aa0a6; + --text-3: #80868b; + --line: #3c4043; + --line-soft: #2e3134; + --surface: #131314; + --surface-2: #1b1c1d; + --surface-3: #282a2c; + --crosshair-dot: var(--text); + color-scheme: dark; +} + +html[data-theme="dark"] body { background: var(--surface); color: var(--text); } +html[data-theme="dark"] .gf-header, +html[data-theme="dark"] .price-chart, +html[data-theme="dark"] .chart-host, +html[data-theme="dark"] .index-card, +html[data-theme="dark"] .mini-card, +html[data-theme="dark"] .panel, +html[data-theme="dark"] .acct-card, +html[data-theme="dark"] .auth-page { background: var(--surface); } +html[data-theme="dark"] .chart-toolbar { background: var(--surface-2); } +html[data-theme="dark"] .chart-toolbar .tb-item, +html[data-theme="dark"] .btn-outline, +html[data-theme="dark"] .google-btn, +html[data-theme="dark"] .form-row input, +html[data-theme="dark"] .form-row select, +html[data-theme="dark"] .range-tabs { background: var(--surface-2); + color: var(--text); border-color: var(--line); } +html[data-theme="dark"] .gf-suggest, +html[data-theme="dark"] .tb-pop, +html[data-theme="dark"] .add-pop, +html[data-theme="dark"] .settings-pop, +html[data-theme="dark"] .chart-readout { background: var(--surface-3); + box-shadow: 0 4px 14px rgba(0,0,0,.6); } +html[data-theme="dark"] .chart-stamp { background: var(--surface-3); } +html[data-theme="dark"] .price-chart .grid { stroke: #2a2d2f; } +html[data-theme="dark"] .price-chart .vgrid { stroke: #232527; } +html[data-theme="dark"] .price-chart .vol { opacity: .6; } +html[data-theme="dark"] .q-table tbody tr:hover { background: var(--surface-2); } +html[data-theme="dark"] .q-table .sym img, +html[data-theme="dark"] .source-stack img, +html[data-theme="dark"] .q-ident img, +html[data-theme="dark"] .news-meta img, +html[data-theme="dark"] .updates .u-pub img { background: #f1f3f4; } +html[data-theme="dark"] .btn-blue { background: #8ab4f8; color: #062e6f; } +html[data-theme="dark"] .add-pill.added { background: #004a77; color: #c2e7ff; } +html[data-theme="dark"] .gf-signin { background: #8ab4f8; color: #062e6f; } +html[data-theme="dark"] .chart-toolbar .tb-moments.on { background: #3a2f14; + border-color: #5c4a1c; } +html[data-theme="dark"] .tb-item.on { background: #1f3a5f; border-color: #2c4f7c; } +html[data-theme="dark"] .gf-wordmark .g-b { color: #8ab4f8; } +html[data-theme="dark"] .gf-wordmark .g-r { color: #f28b82; } +html[data-theme="dark"] .gf-wordmark .g-y { color: #fdd663; } +html[data-theme="dark"] .gf-wordmark .g-g { color: #81c995; } +html[data-theme="dark"] .gf-brand-sub { color: var(--text-2); } + +/* header settings menu */ +.settings-menu { position: relative; } +.settings-menu > summary { list-style: none; cursor: pointer; } +.settings-menu > summary::-webkit-details-marker { display: none; } +.settings-pop { + position: absolute; right: 0; top: 44px; z-index: 40; min-width: 240px; + background: #fff; border-radius: 10px; padding: 6px 0; + box-shadow: 0 4px 14px rgba(32,33,36,.28); +} +.settings-group { padding: 6px 0; } +.settings-group + .settings-group { border-top: 1px solid var(--line-soft); } +.settings-label { display: block; padding: 8px 16px 4px; font-size: 12px; + color: var(--text-2); } +.settings-pop a { display: flex; align-items: center; gap: 12px; + padding: 9px 16px; font-size: 13px; } +.settings-pop a:hover { background: var(--surface-3); } +.settings-pop a .tick { width: 14px; color: var(--blue); } +.settings-pop a.on { font-weight: 500; } + +/* card sparklines follow the same variables as the main chart */ +.spark-line.up { stroke: var(--up); } +.spark-line.down { stroke: var(--down); } +.spark-fill.up { fill: var(--up); opacity: .16; } +.spark-fill.down { fill: var(--down); opacity: .14; } diff --git a/sites/google_finance/static/icons/favicon.png b/sites/google_finance/static/icons/favicon.png new file mode 100644 index 00000000..3f863b4a Binary files /dev/null and b/sites/google_finance/static/icons/favicon.png differ diff --git a/sites/google_finance/static/icons/finance_120.png b/sites/google_finance/static/icons/finance_120.png new file mode 100644 index 00000000..2b25bae7 Binary files /dev/null and b/sites/google_finance/static/icons/finance_120.png differ diff --git a/sites/google_finance/static/icons/finance_192.png b/sites/google_finance/static/icons/finance_192.png new file mode 100644 index 00000000..c4d51179 Binary files /dev/null and b/sites/google_finance/static/icons/finance_192.png differ diff --git a/sites/google_finance/static/icons/finance_512.png b/sites/google_finance/static/icons/finance_512.png new file mode 100644 index 00000000..98244790 Binary files /dev/null and b/sites/google_finance/static/icons/finance_512.png differ diff --git a/sites/google_finance/static/js/finance.js b/sites/google_finance/static/js/finance.js new file mode 100644 index 00000000..8fb24b6f --- /dev/null +++ b/sites/google_finance/static/js/finance.js @@ -0,0 +1,141 @@ +/* Ticker autocomplete for the header search box. + Progressive enhancement only: the form posts to /search either way, so an + agent that never runs JS still has a working search path. */ +(function () { + var input = document.getElementById('gf-q'); + var list = document.getElementById('gf-suggest'); + if (!input || !list) return; + + var timer = null; + var lastQuery = ''; + + function close() { + list.hidden = true; + list.innerHTML = ''; + input.setAttribute('aria-expanded', 'false'); + } + + function render(items) { + if (!items.length) { close(); return; } + list.innerHTML = items.map(function (it) { + var chg = it.change_pct === null ? '' : + '' + + (it.change_pct >= 0 ? '+' : '') + it.change_pct.toFixed(2) + '%'; + return '
  • ' + + '' + it.ticker + '' + + '' + it.name + '' + chg + '
  • '; + }).join(''); + list.hidden = false; + input.setAttribute('aria-expanded', 'true'); + } + + input.addEventListener('input', function () { + var q = input.value.trim(); + if (q === lastQuery) return; + lastQuery = q; + clearTimeout(timer); + if (q.length < 1) { close(); return; } + timer = setTimeout(function () { + fetch('/autocomplete?q=' + encodeURIComponent(q)) + .then(function (r) { return r.json(); }) + .then(render) + .catch(close); + }, 140); + }); + + input.addEventListener('keydown', function (e) { + if (e.key === 'Escape') close(); + }); + + document.addEventListener('click', function (e) { + if (!list.contains(e.target) && e.target !== input) close(); + }); +})(); + +/* Chart hover: crosshair, price/volume read-out and a timestamp chip. + The chart itself is server-rendered SVG; this only adds the pointer layer, + reading the same series the server used so the two can never disagree. */ +(function () { + var host = document.querySelector('.chart-host'); + if (!host) return; + var svg = host.querySelector('.price-chart'); + var cross = host.querySelector('.crosshair'); + var readout = host.querySelector('.chart-readout'); + var stamp = host.querySelector('.chart-stamp'); + if (!svg || !cross) return; + + var S; + try { S = JSON.parse(host.getAttribute('data-series')); } catch (e) { return; } + if (!S || !S.values || S.values.length < 2) return; + + var vb = svg.viewBox.baseVal; + var plotW = S.width - S.padL - S.padR; + + function fmtNum(v) { + return v.toLocaleString('en-US', {minimumFractionDigits: S.digits, + maximumFractionDigits: S.digits}); + } + + function fmtVol(v) { + if (v >= 1e9) return (v / 1e9).toFixed(2) + 'B'; + if (v >= 1e6) return (v / 1e6).toFixed(2) + 'M'; + if (v >= 1e3) return (v / 1e3).toFixed(2) + 'K'; + return String(v); + } + + function indexFromEvent(e) { + var r = svg.getBoundingClientRect(); + var xUser = (e.clientX - r.left) / r.width * vb.width; + var t = (xUser - S.padL) / plotW; + var i = Math.round(t * (S.values.length - 1)); + return Math.max(0, Math.min(S.values.length - 1, i)); + } + + function show(e) { + var i = indexFromEvent(e); + var v = S.values[i]; + var x = S.padL + i * plotW / (S.values.length - 1); + + // recompute y with the same scale the server used + var all = S.values.slice(); + if (S.baseline) all.push(S.baseline); + var lo = Math.min.apply(null, all), hi = Math.max.apply(null, all); + var span = (hi - lo) || (Math.abs(hi) * 0.01 || 1); + lo -= span * 0.10; hi += span * 0.10; span = hi - lo; + var PRICE_BOT = 194, PLOT_TOP = 10; + var y = PRICE_BOT - (v - lo) / span * (PRICE_BOT - PLOT_TOP); + + cross.removeAttribute('hidden'); + var line = cross.querySelector('.cross-line'); + line.setAttribute('x1', x); line.setAttribute('x2', x); + var dot = cross.querySelector('.cross-dot'); + dot.setAttribute('cx', x); dot.setAttribute('cy', y); + + var pct = S.baseline ? ((v - S.baseline) / S.baseline * 100) : null; + var cls = (pct === null || pct >= 0) ? 'up' : 'down'; + var html = 'Price:' + + S.prefix + fmtNum(v) + + (pct === null ? '' : + ' (' + (pct >= 0 ? '+' : '') + pct.toFixed(2) + '%)') + ''; + if (S.volumes && S.volumes[i] !== undefined) { + html += 'Volume:' + + '' + fmtVol(S.volumes[i]) + ''; + } + readout.innerHTML = html; + readout.removeAttribute('hidden'); + + var r = svg.getBoundingClientRect(); + stamp.textContent = (S.dates[i] || '') + ' UTC-4 ' + (S.labels[i] || ''); + stamp.style.left = (x / vb.width * r.width) + 'px'; + stamp.removeAttribute('hidden'); + } + + function hide() { + cross.setAttribute('hidden', ''); + readout.setAttribute('hidden', ''); + stamp.setAttribute('hidden', ''); + } + + svg.addEventListener('mousemove', show); + svg.addEventListener('mouseleave', hide); +})(); diff --git a/sites/google_finance/tasks.jsonl b/sites/google_finance/tasks.jsonl new file mode 100644 index 00000000..051a6934 --- /dev/null +++ b/sites/google_finance/tasks.jsonl @@ -0,0 +1,20 @@ +{"web_name": "Google Finance", "id": "Google Finance--0", "ques": "On the home page, switch the market tabs from US to Latin America. How many indexes are listed on that tab? Then open the one trading at the highest index level and report the day high and the day low shown on its page.", "web": "http://localhost:40016/", "upstream_url": "https://www.google.com/finance/"} +{"web_name": "Google Finance", "id": "Google Finance--1", "ques": "Find Coca-Cola (KO) and report the ex-dividend date and the quarterly dividend amount shown on its quote page.", "web": "http://localhost:40016/", "upstream_url": "https://www.google.com/finance/"} +{"web_name": "Google Finance", "id": "Google Finance--2", "ques": "Open NVIDIA's (NVDA) quote page and switch the price chart to the 1Y range. The chart marks key moments on that range and the toolbar reports how many. How many are marked, and what does the most recent one say?", "web": "http://localhost:40016/", "upstream_url": "https://www.google.com/finance/"} +{"web_name": "Google Finance", "id": "Google Finance--3", "ques": "Open the page for Bitcoin (BTC-USD) and report its 52-week high and 52-week low.", "web": "http://localhost:40016/", "upstream_url": "https://www.google.com/finance/"} +{"web_name": "Google Finance", "id": "Google Finance--4", "ques": "Use the currency converter to convert 2,500 US dollars into Japanese yen. Report the converted amount and the exchange rate the page used.", "web": "http://localhost:40016/", "upstream_url": "https://www.google.com/finance/"} +{"web_name": "Google Finance", "id": "Google Finance--5", "ques": "Open Boeing's (BA) quote page, find its News stories section, and open the most recent story listed. On the article page, report the publisher name and the exact publication date shown next to it.", "web": "http://localhost:40016/", "upstream_url": "https://www.google.com/finance/"} +{"web_name": "Google Finance", "id": "Google Finance--6", "ques": "Open Intel's (INTC) Analysis tab. Report the consensus rating label, how many analyst ratings it is based on, and the average price target.", "web": "http://localhost:40016/", "upstream_url": "https://www.google.com/finance/"} +{"web_name": "Google Finance", "id": "Google Finance--7", "ques": "Open Alphabet (GOOGL), go to the Financials tab, and stay on the quarterly income statement. Report the revenue and the net profit margin for the most recent quarter shown.", "web": "http://localhost:40016/", "upstream_url": "https://www.google.com/finance/"} +{"web_name": "Google Finance", "id": "Google Finance--8", "ques": "Open Walmart (WMT), go to the Financials tab and switch to the Balance sheet. Report total assets and total liabilities for the most recent quarter shown.", "web": "http://localhost:40016/", "upstream_url": "https://www.google.com/finance/"} +{"web_name": "Google Finance", "id": "Google Finance--9", "ques": "Open Chevron (CVX), go to the Financials tab, switch the income statement from Quarterly to Annual, and report the revenue reported for the 2025 fiscal year.", "web": "http://localhost:40016/", "upstream_url": "https://www.google.com/finance/"} +{"web_name": "Google Finance", "id": "Google Finance--10", "ques": "Open NVIDIA's (NVDA) quote page and switch the price chart to the 1Y range. What percentage change does the page report over that range?", "web": "http://localhost:40016/", "upstream_url": "https://www.google.com/finance/"} +{"web_name": "Google Finance", "id": "Google Finance--11", "ques": "Open the Energy Select Sector SPDR Fund (XLE) and go to its Holdings tab. Which holding has the largest weight, and what percentage weight does it have?", "web": "http://localhost:40016/", "upstream_url": "https://www.google.com/finance/"} +{"web_name": "Google Finance", "id": "Google Finance--12", "ques": "Go to Microsoft's (MSFT) Earnings tab. Across every quarter listed there, find the one with the largest positive EPS surprise. Report that quarter's label and its surprise percentage.", "web": "http://localhost:40016/", "upstream_url": "https://www.google.com/finance/"} +{"web_name": "Google Finance", "id": "Google Finance--13", "ques": "Compare Lockheed Martin (LMT), RTX (RTX) and Honeywell (HON). Which of the three has the lowest P/E ratio, and what are the P/E ratios of all three?", "web": "http://localhost:40016/", "upstream_url": "https://www.google.com/finance/"} +{"web_name": "Google Finance", "id": "Google Finance--14", "ques": "Open the Most active market list. Take the five most actively traded names at the top, open each one, and report which of those five has the lowest P/E ratio, along with that P/E value.", "web": "http://localhost:40016/", "upstream_url": "https://www.google.com/finance/"} +{"web_name": "Google Finance", "id": "Google Finance--15", "ques": "Open the Climate leaders market list. How many companies are on that board? Then take the six with the largest market capitalisation, open each one, and report which of those six has the highest dividend yield, along with that yield.", "web": "http://localhost:40016/", "upstream_url": "https://www.google.com/finance/"} +{"web_name": "Google Finance", "id": "Google Finance--16", "ques": "Search for \"utility\". Among the results that are Utilities sector stocks, open each one and report which has the highest dividend yield, together with that yield.", "web": "http://localhost:40016/", "upstream_url": "https://www.google.com/finance/"} +{"web_name": "Google Finance", "id": "Google Finance--17", "ques": "Sign in with the demo account by clicking \"Sign in with Google\" and picking alice.j@test.com from the account chooser (the email form with password TestPass123! also works). The account has more than one list. Open the one named \"Dividend income\", then open each holding in it and report which holding has the highest dividend yield and what that yield is.", "web": "http://localhost:40016/", "upstream_url": "https://www.google.com/finance/"} +{"web_name": "Google Finance", "id": "Google Finance--18", "ques": "Log in with the demo account (email: alice.j@test.com, password: TestPass123!). The account has more than one portfolio. Open the one named \"Growth ideas\" and report its total gain percentage and which position in it has the largest percentage gain.", "web": "http://localhost:40016/", "upstream_url": "https://www.google.com/finance/"} +{"web_name": "Google Finance", "id": "Google Finance--19", "ques": "Log in with the demo account (email: alice.j@test.com, password: TestPass123!). Create a portfolio called \"Bank basket\" with 5,000 dollars of starting cash, then add 25 shares of JPMorgan Chase (JPM) at a cost of 300.00 dollars per share. Report the position's market value and its gain percentage as the portfolio page shows them.", "web": "http://localhost:40016/", "upstream_url": "https://www.google.com/finance/"} diff --git a/sites/google_finance/templates/404.html b/sites/google_finance/templates/404.html new file mode 100644 index 00000000..92e38100 --- /dev/null +++ b/sites/google_finance/templates/404.html @@ -0,0 +1,8 @@ +{% extends 'base.html' %} +{% block title %}Not found - Google Finance{% endblock %} +{% block content %} +

    We couldn't find that page

    +

    The symbol or page you asked for isn't listed on Google + Finance. Try searching for a ticker or company name.

    +Back to Finance home +{% endblock %} diff --git a/sites/google_finance/templates/500.html b/sites/google_finance/templates/500.html new file mode 100644 index 00000000..4198c612 --- /dev/null +++ b/sites/google_finance/templates/500.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% block title %}Something went wrong - Google Finance{% endblock %} +{% block content %} +

    Something went wrong

    +

    Try reloading the page.

    +Back to Finance home +{% endblock %} diff --git a/sites/google_finance/templates/_macros.html b/sites/google_finance/templates/_macros.html new file mode 100644 index 00000000..8f786cb1 --- /dev/null +++ b/sites/google_finance/templates/_macros.html @@ -0,0 +1,89 @@ +{# Shared rendering fragments. #} + +{% macro logo(inst, size=26) %} + {% if inst.logo_url %} + {{ inst.name }} logo + {% else %} + + {% endif %} +{% endmacro %} + +{% macro sym_cell(inst) %} +
    + {{ logo(inst) }} + + {{ inst.ticker }}
    + {{ inst.name }} +
    +
    +{% endmacro %} + +{# A market table. `cols` selects which numeric columns to show. #} +{% macro quote_table(rows, spark_svg, fmt_big, fmt_pct, cols='default') %} +
    + + + + + + + + + {% if cols == 'default' %}{% endif %} + {% if cols == 'fx' %}{% endif %} + {% if cols == 'crypto' %}{% endif %} + + + + {% for inst in rows %} + {% set q = inst.quote %} + + + + + + + {% if cols == 'default' %} + + + {% endif %} + {% if cols == 'fx' %} + + + {% endif %} + {% if cols == 'crypto' %}{% endif %} + + {% endfor %} + +
    SymbolPriceChange% ChangeVolumeMkt capDay highDay lowMkt cap
    {{ sym_cell(inst) }}{{ spark_svg(inst, 78, 26) }}{{ '{:,.2f}'.format(q.price) if q.price >= 1 else '{:,.4f}'.format(q.price) }}{{ '{:+,.2f}'.format(q.change) }}{{ fmt_pct(q.change_pct) }}{{ fmt_big(q.volume) }}{{ fmt_big(q.mkt_cap) }}{{ '{:,.4f}'.format(q.day_high) }}{{ '{:,.4f}'.format(q.day_low) }}{{ fmt_big(q.mkt_cap) }}
    +
    +{% endmacro %} + +{% macro mini_card(inst, spark_svg, fmt_pct) %} + {% set q = inst.quote %} + +
    {{ inst.ticker }}
    +
    {{ inst.name }}
    +
    {{ '${:,.2f}'.format(q.price) if q.price >= 1 else '${:,.4f}'.format(q.price) }}
    +
    + {{ fmt_pct(q.change_pct) }} + +
    +
    {{ spark_svg(inst, 168, 54, True) }}
    +
    +{% endmacro %} + +{% macro news_item(a) %} +
    +
    + {% if a.publisher and a.publisher.favicon_url %} + {{ a.publisher.name }} + {% endif %} + {{ a.publisher.name if a.publisher else 'Newswire' }} + · + {{ a.ago() }} +
    + {{ a.headline }} +
    +{% endmacro %} diff --git a/sites/google_finance/templates/_rail_default.html b/sites/google_finance/templates/_rail_default.html new file mode 100644 index 00000000..32f03aa8 --- /dev/null +++ b/sites/google_finance/templates/_rail_default.html @@ -0,0 +1,42 @@ +{# Right rail on non-quote pages: market movers plus the latest headlines. + The live beta puts a Gemini chat panel here; a mirror cannot serve that + deterministically, so the rail carries real market content instead. #} +

    Research

    + +
    Today's gainers
    + +View all gainers + +
    Today's losers
    + +View all losers + +
    Top news
    +
      + {% for a in rail_news %} +
    • + {{ a.headline }} + {{ a.publisher.name if a.publisher else 'Newswire' }} + · {{ a.ago() }} +
    • + {% endfor %} +
    +More news diff --git a/sites/google_finance/templates/_tab_analysis.html b/sites/google_finance/templates/_tab_analysis.html new file mode 100644 index 00000000..f6d4d82f --- /dev/null +++ b/sites/google_finance/templates/_tab_analysis.html @@ -0,0 +1,47 @@ +{% if consensus %} +
    +
    +
    {{ consensus.label }}
    +
    Based on {{ consensus.total }} analyst ratings
    +
    +
    + {% for label in ['Strong buy', 'Buy', 'Hold', 'Sell', 'Strong sell'] %} + {% set n = consensus.counts[label] %} +
    + {{ label }} + + {{ n }} +
    + {% endfor %} +
    +
    + +
    +
    Lowest price target
    +
    {{ '${:,.2f}'.format(consensus.target_low) }}
    +
    Average price target
    +
    {{ '${:,.2f}'.format(consensus.target_mean) }}
    +
    Highest price target
    +
    {{ '${:,.2f}'.format(consensus.target_high) }}
    +
    + +

    Analyst coverage

    +
    + + + + {% for r in ratings %} + + + + + + + {% endfor %} + +
    FirmRatingPrice targetDate
    {{ r.firm }}{{ r.rating }}{{ '${:,.2f}'.format(r.price_target) }}{{ r.rated_on }}
    +
    +{% else %} +

    No analyst coverage is published for {{ inst.name }}.

    +{% endif %} diff --git a/sites/google_finance/templates/_tab_earnings.html b/sites/google_finance/templates/_tab_earnings.html new file mode 100644 index 00000000..607c5a98 --- /dev/null +++ b/sites/google_finance/templates/_tab_earnings.html @@ -0,0 +1,34 @@ +{% if earnings %} +

    Reported results against the consensus estimate that + stood before each release.

    +
    + + + + + + + + + + + + + + {% for e in earnings %} + + + + + + + + + + {% endfor %} + +
    QuarterReportedEPS estimateEPS actualSurpriseRevenue estimateRevenue actual
    {{ e.quarter }}{{ e.report_date }}{{ '${:,.2f}'.format(e.eps_estimate) }}{{ '${:,.2f}'.format(e.eps_actual) }}{{ fmt_pct(e.surprise_pct) }}{{ fmt_big(e.revenue_estimate) }}{{ fmt_big(e.revenue_actual) }}
    +
    +{% else %} +

    No earnings history is published for {{ inst.name }}.

    +{% endif %} diff --git a/sites/google_finance/templates/_tab_financials.html b/sites/google_finance/templates/_tab_financials.html new file mode 100644 index 00000000..d95f442c --- /dev/null +++ b/sites/google_finance/templates/_tab_financials.html @@ -0,0 +1,81 @@ +{% set INCOME_FIELDS = [ + ('revenue', 'Revenue', 'big'), + ('cost_of_revenue', 'Cost of revenue', 'big'), + ('gross_profit', 'Gross profit', 'big'), + ('operating_expense', 'Operating expense', 'big'), + ('operating_income', 'Operating income', 'big'), + ('net_income', 'Net income', 'big'), + ('net_profit_margin', 'Net profit margin', 'pct'), + ('eps', 'Earnings per share', 'money'), + ('ebitda', 'EBITDA', 'big'), + ('effective_tax_rate', 'Effective tax rate', 'pct')] %} +{% set BALANCE_FIELDS = [ + ('cash_and_short_term_investments', 'Cash and short-term investments', 'big'), + ('total_assets', 'Total assets', 'big'), + ('total_liabilities', 'Total liabilities', 'big'), + ('total_equity', 'Total equity', 'big'), + ('price_to_book', 'Price to book', 'num'), + ('return_on_assets', 'Return on assets', 'pct'), + ('return_on_capital', 'Return on capital', 'pct')] %} +{% set CASH_FIELDS = [ + ('net_income', 'Net income', 'big'), + ('cash_from_operations', 'Cash from operations', 'big'), + ('cash_from_investing', 'Cash from investing', 'big'), + ('cash_from_financing', 'Cash from financing', 'big'), + ('net_change_in_cash', 'Net change in cash', 'big'), + ('free_cash_flow', 'Free cash flow', 'big')] %} +{% set FIELDS = INCOME_FIELDS if statement == 'income' + else BALANCE_FIELDS if statement == 'balance' else CASH_FIELDS %} + +
    + {% for key, label in [('income','Income statement'), ('balance','Balance sheet'), ('cashflow','Cash flow')] %} + {{ label }} + {% endfor %} +
    +
    + {% for key, label in [('quarterly','Quarterly'), ('annual','Annual')] %} + {{ label }} + {% endfor %} +
    + +{% if fin_rows %} + {% if statement == 'income' and fin_chart %} + {{ bar_chart_svg(fin_chart.labels, fin_chart.series) }} + {% endif %} + +

    All values in USD. {{ period|capitalize }} figures for + {{ inst.name }} ({{ inst.display_symbol }}).

    + +
    + + + + + {% for r in fin_rows %}{% endfor %} + + + + {% for key, label, style in FIELDS %} + + + {% for r in fin_rows %} + {% set v = r.items().get(key) %} + + {% endfor %} + + {% endfor %} + +
    {{ 'Quarter ending' if period == 'quarterly' else 'Year' }}{{ r.period_label }}
    {{ label }} + {%- if v is none %}— + {%- elif style == 'big' %}{{ fmt_big(v) }} + {%- elif style == 'pct' %}{{ '{:.2f}%'.format(v) }} + {%- elif style == 'money' %}{{ '${:,.2f}'.format(v) }} + {%- else %}{{ '{:,.2f}'.format(v) }}{% endif -%} +
    +
    +{% else %} +

    No {{ statement }} statement is published for + {{ inst.name }}.

    +{% endif %} diff --git a/sites/google_finance/templates/_tab_holdings.html b/sites/google_finance/templates/_tab_holdings.html new file mode 100644 index 00000000..dd49bc94 --- /dev/null +++ b/sites/google_finance/templates/_tab_holdings.html @@ -0,0 +1,75 @@ +{% from '_macros.html' import sym_cell %} + +{% if holdings %} + {# an ETF: what the fund owns #} +

    Top holdings by weight. The remainder of the fund is held + across positions outside the top {{ holdings|length }}.

    +
    + + + + + + {% for h in holdings %} + {% set hi = h.holding %} + + + + + + + + {% endfor %} + +
    SymbolWeightPrice% ChangeSector
    {{ sym_cell(hi) }}{{ '{:.2f}%'.format(h.weight_pct) }}{{ pv(hi, hi.quote.price) }}{{ fmt_pct(hi.quote.change_pct) }}{{ hi.sector }}
    +
    +{% endif %} + +{% if holders %} + {# a stock: who owns it #} +

    Largest institutional holders of {{ inst.name }}, by + percentage of shares outstanding. Change is quarter on quarter.

    +
    + + + + + + + {% for h in holders %} + + + + + + + + {% endfor %} + +
    Holder% of sharesSharesValueChange
    {{ h.firm }}{{ '{:.2f}%'.format(h.pct_held) }}{{ fmt_big(h.shares) }}{{ fmt_big(h.value) }}{{ fmt_pct(h.change_pct) }}
    +
    +{% endif %} + +{% if held_by %} +

    Funds holding {{ inst.ticker }}

    +
    + + + + {% for h in held_by %} + {% set f = h.etf %} + + + + + + + {% endfor %} + +
    FundWeight in fundPrice% Change
    {{ sym_cell(f) }}{{ '{:.2f}%'.format(h.weight_pct) }}{{ pv(f, f.quote.price) }}{{ fmt_pct(f.quote.change_pct) }}
    +
    +{% endif %} + +{% if not holdings and not holders and not held_by %} +

    No holdings data is published for {{ inst.name }}.

    +{% endif %} diff --git a/sites/google_finance/templates/_tab_overview.html b/sites/google_finance/templates/_tab_overview.html new file mode 100644 index 00000000..b090258b --- /dev/null +++ b/sites/google_finance/templates/_tab_overview.html @@ -0,0 +1,35 @@ +{# Key statistics. Column split follows the live layout: price/volume, + dividend/valuation, per-share and company size. #} +
    +
    +
    Open
    {{ pv(inst, q.day_open) }}
    +
    High
    {{ pv(inst, q.day_high) }}
    +
    Low
    {{ pv(inst, q.day_low) }}
    +
    Previous close
    {{ pv(inst, q.prev_close) }}
    + {% if q.mkt_cap %}
    Mkt cap
    {{ fmt_big(q.mkt_cap) }}
    {% endif %} + {% if q.avg_volume %}
    Avg. vol
    {{ fmt_big(q.avg_volume) }}
    {% endif %} + {% if q.volume %}
    Volume
    {{ fmt_big(q.volume) }}
    {% endif %} +
    + +
    + {% if q.dividend_yield %} +
    Dividend yield
    {{ '{:.2f}%'.format(q.dividend_yield) }}
    +
    Quarterly dividend
    {{ '${:,.2f}'.format(q.quarterly_dividend) }}
    +
    Ex-dividend date
    {{ q.ex_dividend_date }}
    + {% elif inst.kind == 'stock' %} +
    Dividend yield
    + {% endif %} + {% if q.pe_ratio %}
    P/E ratio
    {{ '{:,.2f}'.format(q.pe_ratio) }}
    {% endif %} +
    52-wk high
    {{ pv(inst, q.wk52_high) }}
    +
    52-wk low
    {{ pv(inst, q.wk52_low) }}
    +
    + +
    + {% if q.eps %}
    EPS
    {{ '${:,.2f}'.format(q.eps) }}
    {% endif %} + {% if q.beta %}
    Beta
    {{ '{:.2f}'.format(q.beta) }}
    {% endif %} + {% if q.shares_outstanding %}
    Shares outstanding
    {{ fmt_big(q.shares_outstanding) }}
    {% endif %} + {% if inst.employees %}
    No. of employees
    {{ inst.employees }}
    {% endif %} + {% if inst.sector and inst.kind == 'stock' %}
    Sector
    {{ inst.sector }}
    {% endif %} + {% if inst.is_climate_leader %}
    Climate leader
    Yes
    {% endif %} +
    +
    diff --git a/sites/google_finance/templates/account.html b/sites/google_finance/templates/account.html new file mode 100644 index 00000000..4d3a58ef --- /dev/null +++ b/sites/google_finance/templates/account.html @@ -0,0 +1,45 @@ +{% extends 'base.html' %} +{% block title %}Your account - Google Finance{% endblock %} +{% block content %} +

    Your account

    +
    +

    Profile

    +
    +
    +
    Name
    {{ current_user.name }}
    +
    Username
    {{ current_user.username }}
    +
    +
    +
    Email
    {{ current_user.email }}
    +
    Lists
    {{ nav_watchlists|length }}
    +
    +
    +
    Portfolios
    {{ nav_portfolios|length }}
    +
    +
    +
    + +
    +

    Your lists

    + {% if nav_watchlists %} + + {% else %}

    No lists yet.

    {% endif %} +
    + +
    +

    Your portfolios

    + {% if nav_portfolios %} + + {% else %}

    No portfolios yet.

    {% endif %} +
    +{% endblock %} diff --git a/sites/google_finance/templates/account_chooser.html b/sites/google_finance/templates/account_chooser.html new file mode 100644 index 00000000..74630b78 --- /dev/null +++ b/sites/google_finance/templates/account_chooser.html @@ -0,0 +1,49 @@ +{% extends 'base_auth.html' %} +{% block title %}Choose an account{% endblock %} + +{% block content %} +
    +
    + + Google + +

    Choose an account

    +

    to continue to Google Finance

    +
    + +
      + {% for u in users %} + {% set av = avatar_for(u) %} +
    • +
      + + + + +
      +
    • + {% endfor %} +
    • + + + Use another account + +
    • +
    + +

    + Before using this app, you can review Google Finance's + privacy policy and + terms of service. +

    +
    +{% endblock %} diff --git a/sites/google_finance/templates/base.html b/sites/google_finance/templates/base.html new file mode 100644 index 00000000..1ed83d59 --- /dev/null +++ b/sites/google_finance/templates/base.html @@ -0,0 +1,189 @@ + + + + + + {% block title %}Google Finance{% endblock %} + {# The real Google Finance icon set, mirrored from gstatic.com/finance/favicon/ #} + + + + + + + +
    + + + Google + + Finance + + + + + +
    + +
    + + + +
    + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
    + {% for cat, msg in messages %} +
    {{ msg }}
    + {% endfor %} +
    + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
    + + +
    + + + + +{% block scripts %}{% endblock %} + + diff --git a/sites/google_finance/templates/base_auth.html b/sites/google_finance/templates/base_auth.html new file mode 100644 index 00000000..c4745909 --- /dev/null +++ b/sites/google_finance/templates/base_auth.html @@ -0,0 +1,37 @@ + +{# Standalone shell for the account chooser. The real accounts.google.com step + leaves the app entirely — no product header, no rails — so mirroring it + inside the Finance shell would be wrong, and it also gives an agent a clear + "I have navigated away and come back" boundary. #} + + + + + {% block title %}Sign in - Google Accounts{% endblock %} + + + + +
    + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
    + {% for cat, msg in messages %} +
    {{ msg }}
    + {% endfor %} +
    + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
    + + + + diff --git a/sites/google_finance/templates/compare.html b/sites/google_finance/templates/compare.html new file mode 100644 index 00000000..09314bfc --- /dev/null +++ b/sites/google_finance/templates/compare.html @@ -0,0 +1,61 @@ +{% extends 'base.html' %} +{% from '_macros.html' import logo %} +{% block title %}Compare instruments - Google Finance{% endblock %} +{% block content %} +

    Compare

    + +
    +
    +
    + + +
    + +
    +
    + +{% if rows %} + {% set FIELDS = [('Price','price'),('Change','change'),('% Change','change_pct'), + ('Open','day_open'),('Day high','day_high'),('Day low','day_low'), + ('Previous close','prev_close'),('52-wk high','wk52_high'), + ('52-wk low','wk52_low'),('Mkt cap','mkt_cap'),('Volume','volume'), + ('Avg. vol','avg_volume'),('P/E ratio','pe_ratio'),('EPS','eps'), + ('Beta','beta'),('Dividend yield','dividend_yield'), + ('Shares outstanding','shares_outstanding')] %} +
    + + + + {% for i in rows %} + + {% endfor %} + + + + {% for label, key in FIELDS %} + + + {% for i in rows %} + {% set v = i.quote[key] %} + + {% endfor %} + + {% endfor %} + + + {% for i in rows %}{% endfor %} + + +
    Metric{{ i.ticker }}
    {{ label }} + {%- if v is none %}— + {%- elif key in ['mkt_cap','volume','avg_volume','shares_outstanding'] %}{{ fmt_big(v) }} + {%- elif key in ['change_pct','dividend_yield'] %}{{ '{:.2f}%'.format(v) }} + {%- elif key in ['pe_ratio','beta'] %}{{ '{:,.2f}'.format(v) }} + {%- elif key == 'change' %}{{ '{:+,.2f}'.format(v) }} + {%- else %}{{ pv(i, v) }}{% endif -%} +
    Sector{{ i.sector or '—' }}
    +
    +{% else %} +

    Enter two or more tickers to compare them side by side.

    +{% endif %} +{% endblock %} diff --git a/sites/google_finance/templates/converter.html b/sites/google_finance/templates/converter.html new file mode 100644 index 00000000..e1e70fd2 --- /dev/null +++ b/sites/google_finance/templates/converter.html @@ -0,0 +1,57 @@ +{% extends 'base.html' %} +{% block title %}Currency converter - Google Finance{% endblock %} +{% block content %} +

    Currency converter

    + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    + + {% if err %} +

    {{ err }}

    + {% elif result is not none %} +

    + {{ '{:,.4f}'.format(result) }} {{ dst }} +

    +

    {{ '{:g}'.format(amount) }} {{ src }} = + {{ '{:,.4f}'.format(result) }} {{ dst }}  ·  + 1 {{ src }} = {{ '{:,.6f}'.format(rate) }} {{ dst }}

    + {% endif %} +
    + +

    Quoted pairs

    +
    + + + + {% for p in pairs %} + + + + + + + + + {% endfor %} + +
    PairRateChange% ChangeDay highDay low
    {{ p.ticker }} — {{ p.name }}{{ '{:,.4f}'.format(p.quote.price) }}{{ '{:+,.4f}'.format(p.quote.change) }}{{ fmt_pct(p.quote.change_pct) }}{{ '{:,.4f}'.format(p.quote.day_high) }}{{ '{:,.4f}'.format(p.quote.day_low) }}
    +
    +{% endblock %} diff --git a/sites/google_finance/templates/index.html b/sites/google_finance/templates/index.html new file mode 100644 index 00000000..beb5db77 --- /dev/null +++ b/sites/google_finance/templates/index.html @@ -0,0 +1,89 @@ +{% extends 'base.html' %} +{% from '_macros.html' import mini_card %} +{% block title %}Google Finance - Stock Market Prices, Real-time Quotes & Business News{% endblock %} + +{% block content %} + + + + +

    + {{ regions | selectattr('0', 'equalto', region) | map(attribute='1') | first }} market summary +

    + +{% for s in summaries %} + {# native details/summary: an agent can expand any block with one click and + it works with JavaScript switched off #} +
    + +

    {{ s.headline }}

    + + + {% for pub in sources.get(s.id, []) %} + {% if pub.favicon_url %} + {{ pub.name }} + {% endif %} + {% endfor %} + + {{ s.sources_count }} sites + + +
    +
    + {% for para in s.body.split('\n\n') %}

    {{ para }}

    {% endfor %} +
    +
    +{% endfor %} + +

    Latest updates

    +
      + {% for a in updates %} +
    • + {{ a.ago() }} + + {% if a.publisher and a.publisher.favicon_url %} + {{ a.publisher.name }} + {% endif %} + {{ a.publisher.name if a.publisher else 'Newswire' }} + + {{ a.headline }} +
    • + {% endfor %} +
    + +

    Market movers

    + +
    + {% for inst in movers['most-active'] %} + {{ mini_card(inst, spark_svg, fmt_pct) }} + {% endfor %} +
    +{% endblock %} diff --git a/sites/google_finance/templates/list_detail.html b/sites/google_finance/templates/list_detail.html new file mode 100644 index 00000000..0697ee23 --- /dev/null +++ b/sites/google_finance/templates/list_detail.html @@ -0,0 +1,53 @@ +{% extends 'base.html' %} +{% from '_macros.html' import sym_cell %} +{% block title %}{{ wl.name }} - Google Finance{% endblock %} +{% block content %} + +

    {{ wl.name }}

    + +
    +

    Add a symbol

    +
    + +
    + + +
    + +
    +
    + +{% if wl.items %} +
    + + + + {% for item in wl.items %} + {% set i = item.instrument %} + + + + + + + + + + {% endfor %} + +
    SymbolPriceChange% ChangeMkt cap
    {{ sym_cell(i) }}{{ spark_svg(i, 78, 26) }}{{ pv(i, i.quote.price) }}{{ '{:+,.2f}'.format(i.quote.change) }}{{ fmt_pct(i.quote.change_pct) }}{{ fmt_big(i.quote.mkt_cap) }} +
    + + +
    +
    +
    +{% else %} +

    This list is empty. Add a symbol above.

    +{% endif %} + +
    + + +
    +{% endblock %} diff --git a/sites/google_finance/templates/list_new.html b/sites/google_finance/templates/list_new.html new file mode 100644 index 00000000..6a43f745 --- /dev/null +++ b/sites/google_finance/templates/list_new.html @@ -0,0 +1,23 @@ +{% extends 'base.html' %} +{% block title %}New list - Google Finance{% endblock %} +{% block content %} + +

    Create a list

    +
    +
    + +
    + + +
    +

    + Lists let you follow a set of symbols and see them side by side.

    + + Cancel +
    +
    +{% endblock %} diff --git a/sites/google_finance/templates/lists.html b/sites/google_finance/templates/lists.html new file mode 100644 index 00000000..ec9972b4 --- /dev/null +++ b/sites/google_finance/templates/lists.html @@ -0,0 +1,46 @@ +{% extends 'base.html' %} +{% block title %}Your lists - Google Finance{% endblock %} +{% block content %} +

    Your lists

    + +
    +

    Create a list

    +
    + +
    + + +
    + +
    +
    + +{% if lists %} + {% for wl in lists %} +
    +

    {{ wl.name }}

    + {% if wl.items %} +
    + + + + {% for item in wl.items %} + {% set i = item.instrument %} + + + + + + + + {% endfor %} + +
    SymbolPriceChange% ChangeMkt cap
    {{ i.ticker }} — {{ i.name }}{{ pv(i, i.quote.price) }}{{ '{:+,.2f}'.format(i.quote.change) }}{{ fmt_pct(i.quote.change_pct) }}{{ fmt_big(i.quote.mkt_cap) }}
    +
    + {% else %}

    This list is empty.

    {% endif %} +
    + {% endfor %} +{% else %} +

    You don't have any lists yet.

    +{% endif %} +{% endblock %} diff --git a/sites/google_finance/templates/login.html b/sites/google_finance/templates/login.html new file mode 100644 index 00000000..4775db68 --- /dev/null +++ b/sites/google_finance/templates/login.html @@ -0,0 +1,39 @@ +{% extends 'base.html' %} +{% block title %}Sign in - Google Finance{% endblock %} +{% block content %} +
    +

    Sign in

    +

    Use your account to keep lists and portfolios.

    + + + Sign in with Google + + +
    or
    + +
    + Demo account: alice.j@test.com / TestPass123! +
    +
    + + {{ form.hidden_tag() }} +
    + {{ form.email.label }} {{ form.email(placeholder='you@example.com') }} + {% for e in form.email.errors %}{{ e }}{% endfor %} +
    +
    + {{ form.password.label }} {{ form.password() }} + {% for e in form.password.errors %}{{ e }}{% endfor %} +
    + +
    +

    Create an account

    +
    +{% endblock %} diff --git a/sites/google_finance/templates/markets.html b/sites/google_finance/templates/markets.html new file mode 100644 index 00000000..475c601c --- /dev/null +++ b/sites/google_finance/templates/markets.html @@ -0,0 +1,19 @@ +{% extends 'base.html' %} +{% from '_macros.html' import quote_table %} +{% block title %}{{ title }} - Google Finance{% endblock %} + +{% block content %} + + +

    {{ title }}

    +{% if rows %} + {% set cols = 'fx' if slug == 'currencies' else 'crypto' if slug == 'cryptocurrencies' else 'default' %} + {{ quote_table(rows, spark_svg, fmt_big, fmt_pct, cols) }} +{% else %} +

    Nothing is listed here right now.

    +{% endif %} +{% endblock %} diff --git a/sites/google_finance/templates/news_detail.html b/sites/google_finance/templates/news_detail.html new file mode 100644 index 00000000..ee98929a --- /dev/null +++ b/sites/google_finance/templates/news_detail.html @@ -0,0 +1,50 @@ +{% extends 'base.html' %} +{% from '_macros.html' import news_item %} +{% block title %}{{ a.headline }} - Google Finance{% endblock %} + +{% block content %} + + +
    +

    {{ a.headline }}

    +
    + {% if a.publisher and a.publisher.favicon_url %} + {{ a.publisher.name }} + {% endif %} + {{ a.publisher.name if a.publisher else 'Newswire' }} + · + {{ a.ago() }} + · + {{ a.published_at.strftime('%b %-d, %Y') }} +
    +
    + {% for para in a.body.split('\n\n') %}

    {{ para }}

    {% endfor %} +
    +
    + +{% if a.links %} +

    Mentioned in this story

    +
    + + + + {% for l in a.links %} + {% set i = l.instrument %} + + + + + + {% endfor %} + +
    SymbolPrice% Change
    {{ i.ticker }} — {{ i.name }}{{ pv(i, i.quote.price) }}{{ fmt_pct(i.quote.change_pct) }}
    +
    +{% endif %} + +

    More stories

    +
    {% for r in related %}{{ news_item(r) }}{% endfor %}
    +{% endblock %} diff --git a/sites/google_finance/templates/news_index.html b/sites/google_finance/templates/news_index.html new file mode 100644 index 00000000..d0192a76 --- /dev/null +++ b/sites/google_finance/templates/news_index.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} +{% from '_macros.html' import news_item %} +{% block title %}Business & financial news - Google Finance{% endblock %} + +{% block content %} +

    News

    + +
    {% for a in articles %}{{ news_item(a) }}{% endfor %}
    +{% endblock %} diff --git a/sites/google_finance/templates/portfolio_detail.html b/sites/google_finance/templates/portfolio_detail.html new file mode 100644 index 00000000..65230303 --- /dev/null +++ b/sites/google_finance/templates/portfolio_detail.html @@ -0,0 +1,85 @@ +{% extends 'base.html' %} +{% from '_macros.html' import sym_cell %} +{% block title %}{{ p.name }} - Google Finance{% endblock %} +{% block content %} + +

    {{ p.name }}

    + +
    +
    +
    Market value
    {{ '${:,.2f}'.format(p.market_value()) }}
    +
    Cash
    {{ '${:,.2f}'.format(p.cash) }}
    +
    +
    +
    Cost basis
    {{ '${:,.2f}'.format(p.cost_basis_total()) }}
    +
    Total gain
    +
    {{ fmt_money_signed(p.gain()) }}
    +
    +
    +
    Total gain %
    +
    {{ fmt_pct(p.gain_pct()) }}
    +
    Day change
    +
    {{ fmt_money_signed(p.day_change()) }}
    +
    +
    + +
    +

    Add a position

    +
    + +
    + + +
    +
    + + +
    +
    + + +
    + +
    +
    + +{% if p.lots %} +
    + + + + + + + {% for lot in p.lots %} + {% set i = lot.instrument %} + + + + + + + + + + + {% endfor %} + +
    SymbolSharesCost per sharePurchasedPriceMarket valueGain
    {{ sym_cell(i) }}{{ '{:g}'.format(lot.shares) }}{{ '${:,.2f}'.format(lot.cost_basis) }}{{ lot.purchased_on }}{{ pv(i, i.quote.price) }}{{ '${:,.2f}'.format(lot.market_value()) }}{{ fmt_money_signed(lot.gain()) }} + {{ fmt_pct(lot.gain_pct()) }} +
    + + +
    +
    +
    +{% else %} +

    No positions yet. Add one above.

    +{% endif %} + +
    + + +
    +{% endblock %} diff --git a/sites/google_finance/templates/portfolios.html b/sites/google_finance/templates/portfolios.html new file mode 100644 index 00000000..bb950e15 --- /dev/null +++ b/sites/google_finance/templates/portfolios.html @@ -0,0 +1,47 @@ +{% extends 'base.html' %} +{% block title %}Your portfolios - Google Finance{% endblock %} +{% block content %} +

    Your portfolios

    + +
    +

    Create a portfolio

    +
    + +
    + + +
    +
    + + +
    + +
    +
    + +{% if portfolios %} +
    + + + + + + + {% for p in portfolios %} + + + + + + + + + + {% endfor %} + +
    PortfolioPositionsCashMarket valueTotal gainTotal gain %Day change
    {{ p.name }}{{ p.lots|length }}{{ '${:,.2f}'.format(p.cash) }}{{ '${:,.2f}'.format(p.market_value()) }}{{ fmt_money_signed(p.gain()) }}{{ fmt_pct(p.gain_pct()) }}{{ fmt_money_signed(p.day_change()) }}
    +
    +{% else %} +

    You don't have any portfolios yet.

    +{% endif %} +{% endblock %} diff --git a/sites/google_finance/templates/quote.html b/sites/google_finance/templates/quote.html new file mode 100644 index 00000000..adabb7cc --- /dev/null +++ b/sites/google_finance/templates/quote.html @@ -0,0 +1,228 @@ +{% extends 'base.html' %} +{% from '_macros.html' import logo, mini_card, news_item %} +{% block title %}{{ inst.name }} ({{ inst.ticker }}) + {%- if inst.kind in ['stock','etf'] %} Stock Price & News{% else %} Price, Real-time Quote & News{% endif %} - Google Finance{% endblock %} + +{% block content %} + + +
    +
    + {% if inst.kind in ['stock', 'etf'] %}{{ logo(inst, 34) }}{% endif %} +

    {{ inst.name }}

    +
    + {% if current_user.is_authenticated and nav_watchlists %} + {# one pill that reports membership and toggles it, like the live control #} +
    + + {% if in_lists %} Added{% else %}+ Add to list{% endif %} + + +
    + {% for wl in nav_watchlists %} +
    + + + +
    + {% endfor %} + + +New list +
    +
    + {% else %} + + Add to list + {% endif %} +
    + +
    + {{ pv(inst, q.price) }} + {# the header change tracks the selected chart range, as on the live site #} + {% if move_pct is not none %} + + + {{ fmt_pct(move_pct) }} ({{ '{:+,.2f}'.format(move_abs) }}) + {{ move_label }} + + {% endif %} + {% if q.after_price %} + + {{ fmt_price(q.after_price) }} + + + {{ fmt_pct(q.after_change_pct) }} ({{ '{:+,.2f}'.format(q.after_change) }}) + + {% endif %} +
    +
    + Closed: {{ market_close_label }}·{{ inst.currency }} + {% if q.after_price %}·After hours {{ after_hours_label }}{% endif %} +
    + +
    + {% set base = {'symbol': inst.slug, 'tab': tab, 'range': rng} %} +
    + {# real menus, built from details/summary so they work without JS #} +
    + + + {{ 'Area' if chart_type == 'area' else 'Line' }} + +
    + {% for key, label in [('area','Area'), ('line','Line')] %} + {{ label }} + {% endfor %} +
    +
    + +
    + + + Compare + +
    + {% for r in related[:4] %} + + {{ inst.ticker }} vs {{ r.ticker }} + {% endfor %} + Compare with… +
    +
    + +
    + + + Indicators{% if ma %} {{ ma|length }}{% endif %} + + +
    + {% for w in ma_windows %} + {% set nxt = (ma | reject('equalto', w) | list) if w in ma else (ma + [w]) %} + + {{ '✓' if w in ma else '' }} {{ w }}-day moving average + {% endfor %} + {% if ma %} + Clear indicators + {% endif %} +
    +
    + + + + Key moments{% if moment_count %} {{ moment_count }}{% endif %} + +
    + {% if overlays %} +
    + {% for ov in overlays %} + {{ ov.label }} + {% endfor %} +
    + {% endif %} + {{ chart_svg(inst, series, (range_pct or 0) >= 0, chart_type, overlays) }} + +
    + + + +{% if tab == 'overview' %} + {% include '_tab_overview.html' %} +{% elif tab == 'financials' %} + {% include '_tab_financials.html' %} +{% elif tab == 'earnings' %} + {% include '_tab_earnings.html' %} +{% elif tab == 'analysis' %} + {% include '_tab_analysis.html' %} +{% elif tab == 'holdings' %} + {% include '_tab_holdings.html' %} +{% endif %} + +{% if related %} +

    {{ 'Related stocks' if inst.kind in ['stock','etf'] else 'Related assets' }}

    +
    + {% for r in related %}{{ mini_card(r, spark_svg, fmt_pct) }}{% endfor %} +
    +{% endif %} + +{% if news %} +

    News stories

    +
    + {% for a in news %}{{ news_item(a) }}{% endfor %} +
    +{% endif %} + +{% if inst.profile %} +

    Profile

    +

    {{ inst.profile }}

    +{% endif %} + +{% if inst.kind in ['stock', 'etf'] and (inst.ceo or inst.headquarters) %} +

    About {{ inst.name }}

    +
    +
    + {% if inst.ceo %}
    CEO
    {{ inst.ceo }}
    {% endif %} + {% if inst.employees %}
    Employees
    {{ inst.employees }}
    {% endif %} + {% if inst.founded %}
    Founded
    {{ inst.founded }}
    {% endif %} +
    +
    + {% if inst.headquarters %}
    Headquarters
    {{ inst.headquarters }}
    {% endif %} + {% if inst.industry %}
    Industry
    {{ inst.industry }}
    {% endif %} +
    Exchange
    {{ inst.exchange or '—' }}
    +
    +
    +{% endif %} +{% endblock %} + +{% block rail %} +

    Research

    +
    Compare
    +

    See {{ inst.ticker }} next to its peers on one page.

    + + Compare {{ inst.ticker }} + + {% if inst.kind == 'stock' %} +
    Sector
    + + {% endif %} + +
    Top news
    +
      + {% for a in rail_news %} +
    • {{ a.headline }} + {{ a.publisher.name if a.publisher else 'Newswire' }} · {{ a.ago() }}
    • + {% endfor %} +
    +{% endblock %} diff --git a/sites/google_finance/templates/register.html b/sites/google_finance/templates/register.html new file mode 100644 index 00000000..20f02899 --- /dev/null +++ b/sites/google_finance/templates/register.html @@ -0,0 +1,18 @@ +{% extends 'base.html' %} +{% block title %}Create account - Google Finance{% endblock %} +{% block content %} +
    +

    Create an account

    +
    + {{ form.hidden_tag() }} + {% for field in [form.name, form.username, form.email, form.password, form.confirm] %} +
    + {{ field.label }} {{ field() }} + {% for e in field.errors %}{{ e }}{% endfor %} +
    + {% endfor %} + +
    +

    Already have an account? Sign in

    +
    +{% endblock %} diff --git a/sites/google_finance/templates/search.html b/sites/google_finance/templates/search.html new file mode 100644 index 00000000..1ae7c211 --- /dev/null +++ b/sites/google_finance/templates/search.html @@ -0,0 +1,22 @@ +{% extends 'base.html' %} +{% from '_macros.html' import quote_table, news_item %} +{% block title %}{{ q }} - Google Finance search{% endblock %} + +{% block content %} +

    {% if q %}Results for “{{ q }}”{% else %}Search{% endif %}

    + +{% if not q %} +

    Type a ticker, a company name, or a sector into the + search box above.

    +{% elif results %} +

    {{ results|length }} matching instrument{{ 's' if results|length != 1 }}.

    + {{ quote_table(results, spark_svg, fmt_big, fmt_pct) }} +{% else %} +

    No instrument matches “{{ q }}”.

    +{% endif %} + +{% if news %} +

    Related news

    +
    {% for a in news %}{{ news_item(a) }}{% endfor %}
    +{% endif %} +{% endblock %} diff --git a/websyn_start.sh b/websyn_start.sh index 4d690d78..ab7c292b 100644 --- a/websyn_start.sh +++ b/websyn_start.sh @@ -1,11 +1,11 @@ #!/bin/bash -# WebSyn startup: launch all 16 mirror sites, then exec the original CMD. +# WebSyn startup: launch all 17 mirror sites, then exec the original CMD. # This preserves the base image's browser env server (port 8100) as PID 1. set -e SITES=(allrecipes amazon apple arxiv bbc_news booking github google_flights google_map google_search huggingface wolfram_alpha - cambridge_dictionary coursera espn merriam_webster) + cambridge_dictionary coursera espn merriam_webster google_finance) BASE_PORT=40000 PID_DIR=/tmp/websyn_pids mkdir -p "$PID_DIR" @@ -17,7 +17,7 @@ for d in "${SITES[@]}"; do cp -a "/opt/WebSyn/$d/instance_seed" "/opt/WebSyn/$d/instance" done -echo "[WebSyn] Starting 16 sites on ports ${BASE_PORT}-$((BASE_PORT + 15))..." +echo "[WebSyn] Starting 17 sites on ports ${BASE_PORT}-$((BASE_PORT + 16))..." for i in "${!SITES[@]}"; do site="${SITES[$i]}" port=$((BASE_PORT + i)) @@ -51,8 +51,8 @@ except Exception: exit(1) ready=$((ready + 1)) fi done - echo " [${elapsed}/${max_wait}s] ${ready}/16 sites ready" - if [ $ready -eq 16 ]; then + echo " [${elapsed}/${max_wait}s] ${ready}/17 sites ready" + if [ $ready -eq 17 ]; then break fi done @@ -78,6 +78,6 @@ done echo "[WebSyn] Starting control server on :8101 (PID 1)..." # Control server becomes PID 1 — receives SIGTERM on `docker stop`, -# keeps the container alive as long as it's running. The 16 site +# keeps the container alive as long as it's running. The 17 site # subprocesses are managed via /tmp/websyn_pids/.pid. exec python3 /opt/control_server.py --port 8101